Skip to content

feat(remote): serve a model on a tailnet GPU machine - #333

Open
volen-silo wants to merge 3 commits into
mainfrom
feat/remote-tailnet-foundation
Open

volen-silo wants to merge 3 commits into
mainfrom
feat/remote-tailnet-foundation

Conversation

@volen-silo

@volen-silo volen-silo commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator
  • If this PR fixes a bug, searched tests/e2e-cucumber/expectations.toml for the fixed ticket ID and removed/narrowed any now-stale xfail rows. — n/a, no bug fix; no xfail rows affected.

Summary

Adds rocm remote: discover GPU machines on your tailnet, check their health, install what they are missing, serve a model on one, and reach it from any of your machines.

$ rocm remote targets --tag gpu
$ rocm remote serve gpu-box qwen2.5-7b-instruct
✓ endpoint: http://gpu-box.tailnet.ts.net:8000/v1

SSH is the control channel, not the data path. Everything that inspects or changes the remote goes over SSH. The inference traffic does not: rocm serve binds loopback on the GPU machine as it always has, and the machine then tells its own Tailscale daemon to forward a tailnet port to it. Nothing runs locally, so the endpoint outlives the command that created it and answers from any of your machines rather than only the one that started it.

Ready for review, not for merge. Two things still need a real tailnet and a real GPU to confirm — see "Not verified" below.

Why this shape

The alternative was a local ssh -L tunnel. Publishing from the remote instead means no local process to supervise, no tunnel PID to track, and an endpoint that survives the terminal that made it. The cost is a hard dependency on Tailscale for serving, and an endpoint that is tailnet-wide rather than point-to-point — which is what drove the one change to existing behaviour below.

Changes existing behaviour

rocm serve --require-api-key. serve grants an API key only to non-loopback binds, reasoning that loopback means "only this machine can reach it". Publishing the port makes that false while leaving the bind address unchanged — which would put an unauthenticated model endpoint on the tailnet. The new flag makes a loopback bind authenticated anyway; remote sessions always set it. Local serving is unchanged.

The key travels to the remote on stdin, never in a command line, since both machines expose command arguments in their process tables.

install.sh download-only and install-from-archive modes. Provisioning never copies the local binary — that only works when both machines share an OS and CPU, and when they do not the copy still lands and still looks installed. The remote fetches its own build; if it cannot reach the release host, this machine fetches one for the remote's platform and pushes it with its checksum and signature so the remote repeats every check. Splitting the trust chain across two machines must not shorten it.

rocm services list --json — the machine-readable listing the remote orchestration reads back, applying the same liveness filter as the table.

Non-obvious decisions

  • Two lifecycles, reported separately. The model server and the publish pointing at it can fail alone. A live model with no endpoint is re-published; a dead one is restarted. Collapsing them hides which.
  • Teardown is confirmed, not assumed. A publish is configuration rather than a process, so it survives reboots — a forgotten one is a GPU endpoint on the tailnet with nothing tracking it. Ownership is established before a port is claimed or released, so a session never takes over or tears down another's forward. A teardown that cannot confirm both halves keeps the session listed rather than dropping the only record of what is still running.
  • Installing ROCm is opt-in and gated on the failure catalog. A machine the catalog recognises as needing a person is refused — the wizard that walks someone through those cannot run over a connection nobody is watching. Passwordless sudo is checked first, because a prompt the control channel will never answer hangs rather than fails.
  • Health checks add almost no logic. Gathering facts already produces a plain snapshot and scoring reads nothing else, so the fetch runs on the remote and the scoring here, against the same catalog. Suggested fixes are rewritten to name the target.
  • Signing-key selection matches install.sh exactly. Both resolve _PATH before _PEM, and both treat an empty value as unset. What they choose between is the trust root a signature is verified against, so the two disagreeing would let a remote provision accept a build a local install would reject — surfacing as a rejected artifact rather than a key mismatch. A forwarded key also blanks the remote's own _PATH, so a value the far side exports for itself cannot beat the one we sent.
  • ROCM_REMOTE_SSH_CONFIG names an alternative ssh config. ssh resolves ~/.ssh/config from the account database rather than from HOME, so there was otherwise no way to point the CLI at a different one. Added while building the end-to-end harness, which could not run without it; independently useful for anyone with a per-project ssh config.

Test plan

  • cargo test --workspace --all-targets, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check, prek run --all-files, scripts/smoke_local.py — all pass.
  • 14 scenarios in tests/e2e-cucumber/features/remote.feature. Eight cover discovery, refusals and the session list. Six need a host on the other end of a real SSH connection, so they carry a @requires-docker gate and skip with a reason where no container runtime exists.
  • tests/remote-ssh/run.sh — 21 checks of the tool contracts against a real OpenSSH server in a container: argument handling, exit-code propagation, a credential delivered on stdin and absent from the command line, file copy, batch-mode refusal, the shape Tailscale Funnel takes in the serve config, and that withdrawing a published endpoint actually removes it.
  • tests/remote-ssh/run-e2e.sh — 25 checks driving the built binary through the whole flow: discover, probe, serve, publish, reconcile status, re-publish after an out-of-band withdrawal, tear down, and refuse to publish over a Funnel-exposed port.
  • Both run on the remote control channel (containerised) CI lane, gated on the heavy path filter.

On a network that intercepts TLS, the container lanes need plain-HTTP package mirrors — docs/testing.md documents the ROCM_TEST_APK_REPOS escape hatch.

Not verified

  • The endpoint carrying traffic. tailscale is a stand-in on both sides of every harness, so publish/withdraw are exercised but no inference request crosses a tailnet. Needs a real two-node tailnet.
  • The tailscale serve command surface. Shapes follow Tailscale's documented CLI and the ServeConfig struct, and the parsing contract is pinned against a stateful stub — but nothing here has spoken to a real daemon.
  • A real GPU. No model is ever loaded; the remote's rocm is a stub.

Open question for review

The Funnel guard is unreachable at the default port. Tailscale Funnel serves only 443, 8443 and 10000. The default tailnet port is 8000, so PublishState::FunnelAllowed — a state variant, four refusal arms, a status line, six unit tests and two container lanes — can only be reached by someone passing --tailnet-port 443 (or 8443/10000). That is not a hole: Funnel exposure is per-port, so a Funnel on 443 does not expose a session published on 8000. But it is a lot of machinery behind an opt-in flag, and Funnel is not mentioned in any user-facing doc. Worth deciding whether to document it, default differently, or drop it.

Risk

Medium. The rocm remote surface is entirely new and additive. The two touch points with existing behaviour are serve's new opt-in flag (loopback serving is unchanged when it is absent) and the installer's new modes (the existing path is untouched). Reviewers may reasonably want the installer change looked at separately given its place in the signed-release trust chain — happy to split it out.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch 4 times, most recently from f2dca4f to 78bf68b Compare September 1, 2026 12:57
Comment thread apps/rocm/src/remote/session.rs Fixed
Comment thread apps/rocm/src/remote/session.rs Fixed
@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 78bf68b to e186e9a Compare September 1, 2026 13:18
@tomastola

Copy link
Copy Markdown
Collaborator

The red E2E tests (GPU) here is shared-runner state, not your change.

That job landed on mi300x-0, whose shared pre-warm tree had torch-2.11.0+rocm7.14.0 sitting inside the ROCm 7.13.0 runtime — a cross-wiring from an earlier run. Multi-arch wheels are published stripped of device code by design, so every vLLM start on that tree dies the same way:

RuntimeError: Engine core initialization failed. See root cause above.

which is exactly the three unexpected failures in this run (93 scenarios (85 passed, 8 failed), 5 of them the known xfails). Full diagnosis in #314.

The runner is repaired — torch is back to 2.11.0+rocm7.13.0 and a real kernel verified on it — and I have re-run the job, so nothing needed from you. E2E tests (Strix Halo, Ubuntu) is a separate failure that I have not looked at.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch 4 times, most recently from 7285717 to 24e057b Compare September 7, 2026 08:13
@volen-silo
volen-silo marked this pull request as ready for review September 7, 2026 08:20
@volen-silo
volen-silo requested a review from a team as a code owner September 7, 2026 08:20

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 24e057b

Summary

Adds rocm remote (~7.8k lines, 31 files): provisions the CLI onto a GPU machine over SSH, serves a model there, and publishes the port onto a Tailscale tailnet, plus a containerised SSH test harness and CI lane. Needs work — the security design is genuinely good, but four added tests cannot fail when the code they guard breaks, and one harness line can expose a baked-in password beyond loopback. Verified: the trust model is loopback bind + per-session API key + tailnet-scoped tailscale serve — I confirmed the remote server is pinned to --host 127.0.0.1 --require-api-key (mod.rs:478-493), that funnel is never invoked (only serve --bg --tcp=, publish.rs:176-186), that the key travels on ssh stdin not argv and is stored 0600 with a path-traversal-guarded session id, that shell_quote covers every user value and is proven against a real sh, and that host trust is delegated to the user's own ssh config with BatchMode=yes (fails closed, no silent TOFU) — the README states all of this plainly, so code and documented model match; on the revert question I checked every added test individually and four fail it (below), while the rest are tied to real functions via a ScriptedTransport that hard-errors on unmatched commands; I refuted a reported "remote skips signature verification" concern by reading install.sh (a pinned release key is present, so public_keys is non-empty and the signature gate fires on the remote too); I ran cargo fmt --check (clean) as my one permitted check, so the red check is not formatting — the two plausible candidates I can argue from the source are the unguarded readiness loop in run-e2e.sh:141-144 and the first-ever activation of @requires-docker scenarios on the required e2e lane via E2E_INCLUDE_DOCKER: "1", but I could not read the CI logs and will not call it flake without them. Blocking: 4 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

tests/remote-ssh/run.sh:84-85 — the container is started with docker run -d -p "127.0.0.1:${PORT}:22" ... || docker run -d -p "${PORT}:22" .... The fallback drops the loopback prefix and publishes sshd on all interfaces, and the image ships a real password account (Dockerfile:39-45: a fixed username/password with PasswordAuthentication yes) whose password is committed to this public repository. On any host where the 127.0.0.1: publish form fails, this silently exposes a guessable-password shell on the LAN for the container's lifetime — on a CI runner or a contributor's laptop. run-e2e.sh:84 correctly uses the loopback bind with no fallback. Fix: drop the || fallback so a failed loopback bind is a hard error.

tests/e2e-cucumber/tests/e2e/remote_steps.rs:283-305 (scenario at features/remote.feature:58-64) — remote-09 is titled "Checking a machine's health never installs anything on it", but its When step deliberately uses no container, so ssh fails at the transport layer before remote_doctor reaches bootstrap::locate_cli (the step's own comment says so). then_doctor_installed_nothing accepts "could not reach" as a pass, and then_doctor_points_at_serve wraps its only assertion in if said.contains("only reads"), which never holds here — a permanently dead assertion. If remote_doctor were changed to call ensure_ready_with(...) and silently provision on a health check, this scenario would pass unchanged. Fix: give it a reachable container with no rocm binary so locate_cli's refusal actually fires, and make then_doctor_points_at_serve unconditional.

tests/remote-ssh/run-e2e.sh:195-198attach is the one stateful step whose effect is never checked. serve (line 167) and stop (line 206) both cross-check the container's real tailscale serve status --json; attach only asserts the printed strings "Endpoint re-published" and "not restarted". The preceding step withdraws the endpoint out of band, so this is precisely where re-publishing matters — yet an attach that printed those lines without re-publishing would go undetected, because the following stop reports success either way and the final expect_absent '"8000"' passes trivially. Fix: add serve_config="$(in_container tailscale serve status --json)"; expect_contains "the endpoint is back" '"8000"' "${serve_config}" right after the attach call.

apps/rocm/src/remote/mod.rs:1413-1425serve_sends_the_key_over_stdin_when_it_starts_the_model never calls serve(). It invokes transport.exec_with_stdin(..., Some("k")) itself, then asserts ScriptedTransport recorded the Some("k") it was just handed — exec_with_stdin pushes stdin unconditionally, so the assertion cannot fail. Its comment claims to guard "the caller actually supplies it", but reverting the real call site at mod.rs:270 from Some(&api_key) to None leaves this green. The command-shape half is already covered by mod.rs:1023. Fix: make serve() accept a &dyn Transport so the real orchestration can be driven through ScriptedTransport, or delete the test rather than leave a false guarantee on the credential path.

Non-blocking

  • apps/rocm/src/remote/provision.rs:130-131 — the comment "the remote can repeat every check this machine made" is overstated: ROCM_CLI_SIGNING_PUBLIC_KEY_PATH/PEM is not forwarded, so an operator using a private-mirror key gets the remote verifying against the pinned production key instead — a hard failure, not a downgrade, but a confusing one. Forward the key vars, or narrow the comment.
  • apps/rocm/src/remote/provision.rs:155ROCM_CLI_ARCHIVE={remote_dir}/{asset} is the only unquoted interpolation into a remote command in the whole module; asset comes from parsing the installer's downloaded: line. Not exploitable today, but it breaks the otherwise-uniform shell_quote discipline.
  • apps/rocm/src/remote/transport.rs:24-28 and tailnet.rs:248-252 — both #[cfg_attr(not(test), allow(dead_code))] comments say "remove this attribute in the change that adds the serve path"; this PR is that change. Leaving them will mask genuinely dead code added later.
  • apps/rocm/src/remote/transport.rs:238-240ConnectTimeout=10 bounds only the handshake; there is no ServerAliveInterval/ServerAliveCountMax and no wall-clock bound on wait_with_output(), so a connection that drops mid-command hangs the CLI indefinitely, including in status's polling loop.
  • tests/remote-ssh/run-e2e.sh:141-144 — the sshd readiness loop falls through after 15s with no success check, unlike the equivalent loop in run.sh:108-113 which hard-fails with a clear message. A slow container start surfaces as a confusing discovery-assertion failure instead; this is my leading in-diff candidate for the red check.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

Addressed all 4 blocking findings and 4 of 5 non-blocking findings from the automated review; skipping one non-blocking item as a follow-up.

Blocking

  • run.sh:84 — removed the || fallback to an all-interfaces bind. A failed loopback bind is now a hard error, so the password account never reaches the LAN.
  • remote-09 (remote_steps.rs / remote.feature) — added an INCLUDE_ROCM_CLI build arg so the fixture image can be built without the rocm binary, and gave remote-09 a Given step that starts that variant. The scenario now reaches locate_cli's refusal for real, and then_doctor_points_at_serve's assertion is unconditional rather than permanently skipped.
  • run-e2e.sh:195 — added a tailscale serve status --json check right after attach, so a re-publish that doesn't actually happen fails the harness instead of only checking printed strings.
  • mod.rs stdin test — split serve() into serve_with_transport() so the test drives the real orchestration through a ScriptedTransport, instead of calling exec_with_stdin directly and asserting on its own input. Reverting the real call site's Some(&api_key) back to None now fails this test (checked by reverting it locally and confirming the failure, then restoring it).

Non-blocking

  • provision.rs:130ROCM_CLI_SIGNING_PUBLIC_KEY_PATH/_PEM are now forwarded to the remote's install.sh, shell-quoted, so a private-mirror signing key actually reaches the remote instead of falling back to the pinned production key.
  • provision.rs:155 — the archive path is now shell_quoted like every other interpolation in the module.
  • transport.rs / tailnet.rs — dropped both stale dead_code attributes; this PR is the change their own comments said to remove them in.
  • run-e2e.sh:141 — the sshd readiness loop now hard-fails with a message instead of falling through silently after 15s.
  • ServerAliveInterval/ServerAliveCountMax (transport.rs:238) — left out of this pass; tracked as a follow-up rather than folded in here.

Verification

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace --all-targets all pass clean.
  • Ran the e2e-cucumber remote-09 scenario against a real container built with no rocm binary — passes, 4/4 steps.
  • Ran tests/remote-ssh/run-e2e.sh end to end against a real container — all checks pass, including the new post-attach publish check.
  • Not verified: a real tailnet or a real GPU. Both harnesses remain the same container-based stand-ins used elsewhere in this PR.

Also replied to and resolved the two CodeQL threads: alerts #778/#779 already report state: fixed on this head.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 9af9830 to 7327243 Compare September 11, 2026 09:29
@siloteemu

siloteemu commented Sep 11, 2026

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · 486054f

This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds rocm remote — provision, serve, publish and tear down a model on a tailnet GPU machine over ssh — with unit, cucumber and container-backed e2e coverage. Outcome: Needs work — both prior blockers are genuinely fixed, but three new issues surfaced, two of them repeats of the same two defect classes one layer away from where they were fixed. Verified: ran cargo test -p rocm --bin rocm remote:: (130 passed, 0 failed) and read install.sh and provision.rs side by side — the signing-key precedence now genuinely matches (_PATH > _PEM > pinned in both, pinned by a_path_wins_over_a_pem_because_that_is_what_install_sh_does, which fails if reverted), the forwarded fragment really does blank the remote's _PATH, the head commit's doc walk-back is accurate to the code, transport.rs really does check ssh's 255 before the writer-thread result (an_unreachable_host_says_so_even_when_a_payload_was_being_written fails if reverted, and its 1 MiB payload makes the EPIPE deterministic rather than racy); leak scan across the diff is clean, all 19 commits are signed and carry a matching DCO sign-off, no prompt-injection content anywhere in the checkout. Blocking: 3 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

1. apps/rocm/src/main.rs:5972 — the --require-api-key guard is passed a hardcoded false, disabling it at this call site.

ensure_public_service_has_endpoint_key(host, endpoint_key_file.is_some(), false)?;

ensure_public_service_has_endpoint_key (main.rs:5701) has two branches: a public bind without a key, and requires_api_key && !key_present. The second is the one this PR adds for exactly the threat it introduces — a loopback bind is no longer "only this machine" once the remote republishes the port onto the tailnet. But record.requires_api_key is computed 40 lines earlier (main.rs:5931) and is in scope, and this call passes the literal false instead. The other three call sites (restart_internal_managed_service at main.rs:15805, and both sites in apps/rocmd/src/lib.rs) pass the real value; this one is the outlier.

It is reachable, not merely theoretical, because the two values are computed by different tests: record.requires_api_key comes from file existence, while endpoint_key_file is filtered by validity (endpoint_api_key_from_file) — a distinction the comment immediately above spells out as the reason an "empty or malformed key file would otherwise satisfy the guard". So an existing-but-invalid key file yields requires_api_key = true, key_present = false: precisely the case the new branch exists to catch, and the hardcoded false lets it spawn an unauthenticated listener for a service the user explicitly asked to require a key.

This also repeats prior finding 1's shape: the comment on the line above ("enforce the invariant here too rather than relying on every future caller having done so") claims the invariant is enforced, and only half of it is.

Fix: ensure_public_service_has_endpoint_key(host, endpoint_key_file.is_some(), record.requires_api_key)?; and add a test that a present-but-empty key file on a --require-api-key loopback service refuses to spawn.

2. apps/rocm/src/remote/mod.rs:685 — a definite remote failure is collapsed into "the machine could not be asked".

publish: publish::publish_state(transport, record.tailnet_port, record.remote_port).ok(),

publish_state (publish.rs:132-148) returns Err in two materially different cases: the transport failed, or the remote was reached and tailscale serve status --json exited non-zero, in which case the error carries the exit code and the remote's own stderr (e.g. tailscale: command not found). .ok() discards both into None, which render_status (mod.rs:770) prints as "unknown — the machine could not be asked" — telling the user the machine was never asked when in fact it answered with a concrete, actionable reason.

This is the same defect class as prior blocking finding 2, one layer up and still present. The inconsistency is visible within the same function: forty lines earlier the very same code path carefully separates ServerHealth::Error (reached, failed) from ServerHealth::Unreachable (never reached), and the module docs at mod.rs:17-22 make that separation the stated design. Nothing in the test suite covers it — no test scripts a successful services list followed by a failing serve status.

Fix: carry the error rather than dropping it — make the field Result<PublishState, String> (or add a PublishState::Unreachable(String)), have render_status print the captured stderr, and add a ScriptedTransport test pinning that a reached-but-failed serve status is reported differently from an unreachable host.

3. tests/e2e-cucumber/tests/e2e/remote_steps.rs:297-305 — a Then step that asserts nothing when its guard does not match.

async fn then_doctor_points_at_serve(world: &mut E2eWorld) {
    let said = said(world);
    if said.contains("only reads") {
        assert!(said.contains("rocm remote serve"), "{said}");
    }
}

If the output does not contain "only reads", the step passes having verified nothing. Its partner step at remote_steps.rs:291-294 has the matching escape hatch (said.contains("only reads") || said.contains("could not reach")), and the preceding assert_ne!(cli_rc, Some(0)) is satisfied by any failure. Together they mean scenario remote-09 can go fully green while asserting only "the command exited non-zero somehow" — CI stays green as the coverage silently disappears.

This is the standing test-vacuity failure mode, and it is in the remediation itself: commit 6f3a81e (test(remote): give remote-09 a reachable container to check) made the container reachable precisely so the real branch is taken, but left the tolerance for unreachability in place. With reachability now guaranteed by given_reachable_machine_without_cli, the fallbacks are dead permissiveness.

Fix: drop both escape hatches — assert said.contains("rocm remote serve") unconditionally, and drop || said.contains("could not reach") — so an unexpected path fails loudly instead of passing quietly.

Non-blocking

  • apps/rocm/src/remote/provision.rs:173remove_dir_all(&staging) is only reached on success; every ?/bail! above it leaks the staging dir, so repeated failed --install-rocm runs accumulate under $TMPDIR (0700 + nonce-named, so not a security issue — but the asymmetry reads as an oversight, not the deliberate keep-it-for-debugging choice it might be).
  • apps/rocm/src/remote/provision.rs:96-113run_remote_installer, the path tried first, never forwards the signing override, so the "the key we send wins" guarantee only engages on the fallback; behaviour is fail-safe but nothing says so, and a reader of install_cli would reasonably assume it applies throughout. One comment line fixes it.
  • apps/rocm/src/remote/provision.rs:160-166 — only the pure fragment builder is tested; nothing pins that {signing_env} is actually prefixed onto the remote command, so dropping it in a refactor would pass the whole suite.
  • apps/rocm/src/remote/transport.rs:339-366 — the stdin/stdout deadlock fix is correct by inspection (writer thread + immediate wait_with_output), but no test exercises a reachable host that both consumes a large stdin and floods stdout; every current test passes if the fix is reverted.
  • tests/e2e-cucumber/tests/e2e/remote_steps.rs:362-368free_port() binds, reads the port, drops the listener, then hands it to docker run; under the 64-way max_concurrent_scenarios this lane uses, that TOCTOU is a plausible intermittent-flake source. Note the CI state here is 19 success / 1 failure / 1 skipped: I cannot see which lane is red and am not claiming this is the cause — that would be an inference I have no way to confirm from the checkout.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

Addressed the second review round.

Blocking finding 1 — publish.rs AllowFunnel blind spot. Fixed. AllowFunnel (keyed host:port) is now parsed into RawServeConfig; when set for the target port, classify() returns a new PublishState::FunnelAllowed, and both publish() and withdraw() bail loudly naming tailscale funnel --tcp=<port> off. SERVE_CONFIG_KEYS now has a comment distinguishing parsed keys from document-shape-only keys. Corrected the module doc's "visible to the whole tailnet, scoped only by its ACLs" claim, which Funnel already contradicted.

Blocking finding 2 — bootstrap.rs install-before-tailscale-check. Fixed. The tailscale_present check is now hoisted above the rocm_present branch, so a machine without Tailscale is refused before any install runs, regardless of what else is missing. Extended a_machine_without_tailscale_is_refused_before_a_model_is_started with an install_rocm = true, neither-present fixture; confirmed it fails without the hoist.

Non-blocking items — none skipped, all five addressed:

  • provision.rs:164 — now reads _PATH locally and forwards its content as _PEM; an explicit _PEM still wins, matching install.sh's own resolution order. Split into a pure, dependency-injected helper with unit tests for all four cases (neither set, explicit-PEM-wins, PATH-read-and-forwarded, PATH-read-failure-reported).
  • publish.rs:34,144 — the Services key had the same blind spot as AllowFunnel; fixed alongside finding 1.
  • transport.rs:291 — fixed. The stdin write now happens on its own thread instead of being sequenced before wait_with_output(), removing the deadlock risk for a payload larger than the OS pipe buffer paired with remote output.
  • provision.rs:234 — the staging dir is now salted with a nanosecond nonce and created with create_dir (not _all), so a pre-staged/symlinked path can't be silently adopted; restricted to 0700.
  • transport.rs:269scp_argv now rejects a local_path/remote_path starting with -, mirroring validate_destination's existing guard for the ssh destination.

Verification (local, on top of the branch's current merge with main):

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — zero warnings
  • cargo test --workspace --all-targets — every test result: line workspace-wide reports 0 failed

Commits: 0e7adb38, e3613fe2, 2cc06956, de914c0c.

One open item, not part of this review round's findings: origin/main advanced again after these commits were prepared (one new commit, a9937493), and merging it into this branch is currently blocked by pre-existing staged, uncommitted changes in this worktree unrelated to this review (including staged deletions of two source files). That's being sorted out separately and isn't a gap in this review response.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · de914c0

This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds rocm remote (serve/attach/stop/status/doctor/targets) driving a tailnet GPU machine over SSH, plus a containerised SSH test lane — Needs work. Verified: ran cargo test -p rocm --bin rocm remote:: (126 passed, 0 failed); confirmed both prior blocking findings are genuinely fixed — the Funnel classifier now checks AllowFunnel before the forward lookup and both publish and withdraw bail naming tailscale funnel --tcp=<port> off, and the tailscale prerequisite is hoisted above the install branch with a revert-sensitive test; also confirmed the credential is delivered over stdin (never argv) and the diff carries no internal leaks or injected instructions. Two new defects in the remediation commits, both verified against source. Blocking: 2 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

1. apps/rocm/src/remote/provision.rs:195-218 — signing-key precedence is the inverse of install.sh, and the doc comment claims otherwise.
signing_env_fragment_from matches pem_env first and only falls back to path_env. install.sh:99-110 (resolve_public_keys) does the opposite: it returns ROCM_CLI_SIGNING_PUBLIC_KEY_PATH if set and only falls through to _PEM when it is not. The doc comment at provision.rs:184-186 asserts "an explicit _PEM is forwarded as-is and takes precedence, matching install.sh's own resolution order" — that is factually false, on the selection of a signature trust root. With both variables set locally, a remote provision verifies against a different key than a local install.sh run would; the failure surfaces as "the remote rejected the build we fetched for it", which points at the artifact rather than at the key.

There is a second, sharper edge in the same function: std::env::var(..).ok() yields Some("") for a variable set to the empty string, whereas install.sh's [ -n ... ] treats empty as unset. So ROCM_CLI_SIGNING_PUBLIC_KEY_PEM="" together with a real _PATH makes this code forward an empty _PEM and silently drop the operator's explicit key, and the remote then falls back to the pinned production keys — the operator's chosen trust root is discarded with no diagnostic.

Fix: check path_env before pem_env (or, if the inversion is deliberate, correct the comment and say why), and treat an empty value as unset on both branches. The new test an_explicit_pem_is_forwarded_as_is_and_wins_over_a_path (provision.rs:407-421) currently encodes the wrong order, so it must change with the code — it is why this slipped through. Add a case asserting the order that install.sh actually implements.

2. apps/rocm/src/remote/transport.rs:359-370 — the deadlock fix consults the stdin-writer error before the outcome it now has in hand, discarding ground truth.
wait_with_output() returns first and output already holds ssh's exit code, stdout and stderr. The code then does writer.join()...?? before the SSH_TRANSPORT_FAILURE (255) check at :379, so a write error on the payload aborts the call and throws the captured outcome away. The relevant write error is BrokenPipe: if the child exits and closes stdin before the writer thread is scheduled, write_all gets EPIPE. Moving the write onto a thread widened that window rather than narrowing it — previously the write happened inline immediately after spawn, whereas now the main thread blocks in wait_with_output while the writer waits to be scheduled.

The consequence lands on the one caller that uses this path, serve_with_transport (mod.rs:286): instead of the purpose-built "could not reach {dest} over ssh: {stderr}", an unreachable host can produce "failed to send input to {dest}: Broken pipe", which mod.rs:288-300 then wraps as "lost contact ... so it may or may not be running" and clears the freshly minted key — telling the user the model's state is unknown when the transport in fact reported 255 and nothing started. The call-site comment "this fails on a broken pipe while sending the key ... so the model's state is genuinely unknown from here" was true before the fix and is now stale.

Fix: evaluate the 255 check and build RemoteOutcome from output first; only surface a writer error when the process outcome does not already explain the failure (treat ErrorKind::BrokenPipe as advisory once output is in hand). While there, join the writer on the wait_with_output error path too — today it is dropped and detached.

Non-blocking

  • apps/rocm/src/remote/transport.rs:201-216 and :655-672 — the remote-path guard's stated rationale is wrong: the remote argument is built as format!("{destination}:{remote_path}"), so it can never start with - and scp cannot read it as an option; keep the check but fix the comment and the test comment, or a future reader re-derives the same wrong mechanism.
  • apps/rocm/src/remote/provision.rs:284-294create_dir followed by set_permissions(0o700) leaves a umask window, contradicting the adjacent comment's "0700 keeps the contents unreadable"; this repo already has the atomic pattern in apps/rocm/src/dash.rs:255-266 (DirBuilder::new().mode(0o700)), which even documents why.
  • .github/workflows/ci.yml:652-691 — the new remote-ssh job runs cargo build -p rocm with no actions-rust-lang/setup-rust-toolchain step and no rust cache, unlike every other cargo job in this workflow; a cold uncached build of this workspace against the 30-minute timeout is a plausible cause of the single failing check, but I am inferring that from the workflow source and cannot confirm it — no lane names were available to me, and the un-merged base may equally explain it.
  • tests/remote-ssh/fake-tailscale.sh — the fake only ever emits {"TCP": ...}, never AllowFunnel, Foreground or Services, so the exposure classifier's safety branches (the subject of the prior blocking finding) are proven only against ScriptedTransport fixtures, not against anything shaped like the real daemon.
  • apps/rocm/src/remote/mod.rs:747 — the FunnelAllowed status line leads with "no", but that state is also reached when our own forward is live (Funnel is checked first and short-circuits); phrase it as an exposure warning rather than a "not published" answer.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from de914c0 to 486054f Compare September 11, 2026 15:26
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Addressed the review on de914c0c, plus a rebase onto main. Force-pushed, so the review's line references point at commits that no longer exist — summary of what moved:

Both blocking findings fixed, each reproduced first.

  • Signing-key precedence. _PATH now resolves before _PEM, matching resolve_public_keys, and an empty value counts as unset on both sides. The test that encoded the old order is replaced rather than kept. Verified by reverting each half separately: the old order yields pem-content where install.sh would use path-content, and without the empty-value filter an empty _PEM forwards PEM='' and silently discards the operator's real key.
  • Transport ordering. The outcome is built and the 255 check runs before the writer result, which is now demoted to a symptom only when the command also failed. The regression test needed a payload larger than a pipe buffer — a short one lands in the buffer and returns success with no reader, so the bug is timing-dependent at that size and the test passed against the broken code. At 1 MiB it fails 3/3 before and passes 3/3 after.

All five non-blocking items fixed, including the scp guard rationale (the remote argument is prefixed with the destination, so that half is a shape check rather than a safety one) and the Funnel status line.

One finding I did not take. The CI lane suggestion assumed the missing toolchain explained the red check. It did not — remote control channel (containerised) was already passing; the failure was clippy, from a semantic conflict with main over resolve's signature. Fixed by rebasing and updating the three call sites. The toolchain step is still added, as a consistency fix.

Beyond the review, worth flagging:

  • Forwarding a signing key now also blanks the remote's own ROCM_CLI_SIGNING_PUBLIC_KEY_PATH. Without it a key could be forwarded correctly and still lose, since resolve_public_keys reads _PATH first and /etc/environment reaches non-interactive sshd sessions.
  • The container fake accepted funnel --tcp=8000, but Funnel serves only 443/8443/10000 — it was encoding a state tailscaled cannot emit. Fake and fixtures now use real Funnel ports. That surfaced the open question now in the PR description: the guard is unreachable at the default tailnet port.

Local: full workspace tests, clippy -D warnings, fmt, prek, and both container lanes (21 and 25 checks) all pass on the rebased tree.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

CI status: 20 of 21 checks green, including clippy (the one that was red before the rebase), remote control channel (containerised), build-and-test, windows-build-and-test and Commit signatures + sign-off.

The one red check, E2E tests, is inherited from main and not from this branch:

  • It fails on exactly two scenarios, dash-gen-tps-held-after-scrape-failure and dash-gen-tps-expiry-boundary.
  • The same two fail on main's own HEAD (a6fa76db) with an identical reconciliation line. The commit before it (a9937493) was green, so the regression arrived with feat(therock): support ROCm 10 "next" install layout, additive (EAI-8431) #329.
  • Re-ran the job to rule out a flake — same two scenarios, deterministically.
  • This branch touches no dash code. git diff --name-only origin/main...HEAD returns nothing matching dash. The only file this branch shares with that area is expectation.rs, and the three call sites changed here are inside #[cfg(test)], so they cannot affect scenario resolution at runtime.

Flagging rather than fixing: a dash regression is unrelated to this PR and belongs in its own change. Happy to pick it up separately if that is useful.

@siloteemu
siloteemu dismissed their stale review September 11, 2026 16:06

pr-review-watcher · de914c0 — superseded, withdrawn.

Both blocking findings from that round are genuinely fixed at the current head, and each was re-verified here rather than taken from the summary: the signing-key precedence now matches the install script in both order and empty-value handling, pinned by a test that fails when the order is reverted; and a cleanly failed transport is now reported as a failure rather than as indeterminate, pinned by a regression test whose payload size makes the condition deterministic instead of timing-dependent.

On the earlier CI-lane point: the author is right and that finding was wrong. It asserted a cause for a red check that this automation had no way to confirm, and should have been stated as an inference or not at all.

A fresh review of the current head is posted separately.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 486054f

This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Both findings from the previous round are genuinely fixed, and the earlier change request has been withdrawn. Blocking: 3 · Non-blocking: 5. Three new issues at this head, two of them the same two defect classes one layer away from where they were fixed. Full detail is in the review comment on this PR.

1. The API-key guard is bypassed at one of its four call sites. ensure_public_service_has_endpoint_key is passed a hardcoded false for requires_api_key in apps/rocm/src/main.rs, while the real value is already in scope and the other three call sites pass it. Reachable, because the guard's two inputs are computed by different tests — one by file existence, one by file validity — so an existing-but-invalid key file is exactly the case the new branch was added to catch, and it spawns an unauthenticated listener instead. The comment above the line says the invariant is enforced here; only half of it is. Fix: pass the real value, and add a test for a present-but-empty key file on a service that requires one.

2. A definite remote failure is reported as "the machine could not be asked." In apps/rocm/src/remote/mod.rs, publish_state(...).ok() discards two materially different errors into one: transport failure, and the remote answering with a non-zero exit and its own stderr. The user is told the machine was never reached when it in fact replied with an actionable reason. This is the same class as the finding just fixed in the transport layer, one layer up — and the same function separates reached-but-failed from never-reached forty lines earlier, which is also what the module docs describe. Fix: carry the error instead of dropping it, and pin the distinction with a test.

3. A test step asserts nothing when its guard does not match. In tests/e2e-cucumber/tests/e2e/remote_steps.rs, the step passes unconditionally unless the output contains a particular phrase, and its partner step carries a matching escape hatch. Together the scenario can go green having checked only that the command exited non-zero. The commit in this round that made the container reachable removed the reason those fallbacks existed but left them in place, so they are now dead permissiveness that will hide the coverage disappearing. Fix: drop both escape hatches so an unexpected path fails loudly.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 486054f to b014f52 Compare September 14, 2026 09:02
@volen-silo

Copy link
Copy Markdown
Collaborator Author

History rewritten: 19 commits squashed to 3, and rebased onto current main. The tree is byte-identical to the pre-squash one (same tree hash) — only the history changed. Line anchors in the older review comments no longer resolve; the reasoning is preserved in this thread.

The three commits are feature / tests / docs. Further review rounds will amend these rather than stack more fix commits on top.

The three blocking findings — all verified, and all older than the last round. git log -L puts each on a 2026-09-01 commit, i.e. the PR's original work, not the remediation. They were in the tree at the previously reviewed head and were not reported then, so this is the review surfacing a deeper slice rather than a regression.

  1. The managed-spawn guard was passed a literal false. Fixed to pass record.requires_api_key. The test is the part that mattered: a unit test on the guard function cannot catch this — the function was always correct, the wiring was not. The new test plants an empty key file, which is the only state where the guard's two inputs disagree (existence says a key is required, validity says none is present), and drives spawn_managed_engine_child on a loopback host so the public-bind branch cannot be what refuses it. Confirmed it fails with the literal restored.
  2. publish_state(..).ok() collapsed reached-but-failed into never-reached. The same function makes exactly that distinction 28 lines earlier for services list, with a comment saying why. Added PublishObservation mirroring the ServerHealth split; publish_state keeps its signature so the four callers in publish.rs are untouched. status now prints the remote's own words. Confirmed the test fails when the collapse is restored.
  3. Two dead escape hatches in remote-09. Both steps belong to that scenario alone, whose Given starts a real container, so the unreachable branch was unreachable. Dropped; the scenario still passes, which is what shows the hatches were carrying nothing.

All five non-blocking items are also fixed, including a Drop guard so the staging directory is removed on the error paths too, and a real test for the stdin/stdout deadlock fix. That last one is worth a note: my first attempt ran the child's output and input concurrently, and it passed with the fix reverted — i.e. it tested nothing. Sequencing the flood before the read reproduces the hang, and it now times out at 10s reverted and passes in 0.03s fixed.

Beyond the named findings, I swept the diff for each defect class rather than just the sites reported. That turned up one more instance of the same collapse: the session-listing path built a SessionObservation with ServerHealth::Error after SshTransport::new failed — but Error means "answered unreadably" and nothing had been sent, so it is Unreachable, and the constructor's reason was being discarded. Both fixed. The comment sweep and the literal-argument sweep came back clean.

Verified locally on the rebased tree: full workspace tests, clippy -D warnings, fmt, prek, both container lanes (21 and 25 checks), and all 14 remote cucumber scenarios with E2E_INCLUDE_DOCKER=1.

@siloteemu
siloteemu dismissed their stale review September 14, 2026 09:44

Dismissing this as superseded. Re-reviewed at b014f52: all three counts are resolved and I verified them rather than taking the summary on trust - the managed-spawn guard now receives the record's own flag (confirmed load-bearing by reverting the call site in a scratch copy, which makes the new test fail), the reached-but-failed versus never-reached split is restored and pinned by a test asserting the two render differently, and all 23 then-steps were walked for any input under which no assertion runs. A separate, newly-found instance of the same fail-open class is filed as a fresh change request; this older one is retired so only one objection is live.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · b014f52

This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds rocm remote: discover GPU machines on a tailnet, provision them over SSH, serve a model there and publish its port, plus a containerised SSH test lane and docs. Needs work — all three counts of the standing objection are genuinely resolved, but the author's own sweep for the "collapse a failure into a weaker state" class missed one instance, and it sits on the same security gate as count 1. Verified: read all three commit messages whole from the raw objects (57/52/28 lines, signatures intact, company identity and DCO sign-off present); ran a scratch-copy revert experiment reverting the apps/rocm/src/main.rs guard call site to the hardcoded false and ran cargo test -p rocm --bin rocm a_managed_spawn_refuses_an_invalid_key_file — it FAILED, so that test is genuinely load-bearing rather than passing either way; confirmed publish_state(..).ok() is gone and its replacement is pinned by a test that asserts the two outcomes render differently; walked all 23 #[then] steps in remote_steps.rs and found no remaining vacuous-pass path; leak scan over the diff clean (no internal hostnames, gateways, cluster names or registry paths; ROCM_TEST_APK_REPOS defaults empty and the documented example uses the public Alpine CDN); no prompt-injection content found. Blocking: 1 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

apps/rocmd/src/lib.rs:3239the API-key requirement is silently dropped, and written back to disk, when a record read fails. supervise_service rebuilds the record from scratch (ManagedServiceRecord::new starts requires_api_key false) and restores the flag from disk with load_managed_services(paths).unwrap_or_default(). That call returns Err on a real, informative failure — read_dir, the per-entry ?, or fs::read on any single record file (note it skips unparseable JSON, so Err means an I/O failure, not a corrupt record). .unwrap_or_default() turns that into "no service ever required a key". The next line falls back to key-file presence, which is absent precisely when a service has been stopped — the case the code's own comment three lines above calls out. Both signals then read false, ensure_public_service_has_endpoint_key at :3250 passes, and the service comes back up on a loopback bind with no authentication even though the user launched it with --require-api-key.

Why this blocks rather than being a nit: it is reachable without exotic conditions (a record file deleted by a concurrent rocm services stop between read_dir and fs::read is enough — ENOENT), it fails open on a security gate, and record.write() at :3256 persists the weakened record, so the damage is permanent: every later rocm services restart is disarmed too. That is verbatim the outcome the adjacent comment says must not happen — "rebuilding a record here without restoring it would not just skip the check now — it would write the weakened record back and disarm every later rocm services restart as well." In user-facing terms this is the same sentence as count 1 of the standing objection: a service the user asked to protect starts unprotected. The PR introduces these lines, so it is not pre-existing.

Fix: propagate instead of defaulting — load_managed_services(paths)?. The function already returns Result<()> and uses ? freely a few lines up (paths.ensure()?, fs::create_dir_all(...)?), and failing closed is this file's own stated preference ("an unreachable service is recoverable, an anonymous public one is not"). Add a test that drives the real call site — supervise_service checks the guard before record.write() and before command.spawn(), so the same technique a_managed_spawn_refuses_an_invalid_key_file_on_a_service_that_requires_one uses in the rocm copy works here — asserting both that it refuses and that the on-disk record is not rewritten with requires_api_key: false.

Non-blocking

  • .github/workflows/ci.yml:669 — the new lane is the only job-level if: needs.changes.outputs.heavy == 'true' in the file; every other heavy gate is step-level, and the file documents at :1066 why job-level gating stalls the merge queue for a required check. Harmless while this lane is not a required check; worth confirming that is the intent before it becomes one.
  • apps/rocm/src/remote/mod.rs:903stop() collapses a transport error and a non-zero remote exit into one bool, and the bail names no reason, unlike the withdraw branch immediately above which interpolates {error}. Nothing is misreported (unlike count 2), but the remote's own words are available and dropped.
  • apps/rocm/src/remote/mod.rs:839,867 — no test ever calls attach() or stop(); only render_stopped is tested, with hand-picked booleans. Deleting the early bail!s so the record is removed on an unconfirmed teardown would pass every test in the file.
  • apps/rocmd/src/lib.rs:5228,7571 — the guard tests here call the function directly with literals; neither real call site is driven, so a future miswiring in this copy would go undetected. The rocm copy does it properly and is the model to follow.
  • apps/rocm/src/remote/transport.rs:195 — ~14 literal spaces mid-sentence in a user-facing error message ("would be    read as an option"), an editing artefact.

On the standing objection

Count 1 — "a service the user asks to protect with an API key can start UNPROTECTED when the key file is empty or unreadable"; originally "ensure_public_service_has_endpoint_key is passed a hardcoded false for requires_api_key in apps/rocm/src/main.rs". RESOLVED. Both call sites (apps/rocm/src/main.rs:5978 and :15818) now pass record.requires_api_key, and key_present is validity-filtered through endpoint_api_key_from_file rather than mere file existence. The requested test exists and I verified it is load-bearing rather than taking the claim on trust: on a scratch copy with the call site reverted to the hardcoded false, a_managed_spawn_refuses_an_invalid_key_file_on_a_service_that_requires_one fails.

Count 2 — "a real remote failure is reported to the user as merely unreachable"; originally "publish_state(...).ok() discards two materially different errors into one". RESOLVED. The .ok() is gone; observe now calls publish::observe(...), which preserves the reached-but-failed versus never-reached split the same function already made forty lines earlier. Pinned by a_remote_that_answers_about_publishing_is_not_reported_as_one_that_was_never_asked, which asserts the remote's own stderr reaches the status line and that the two cases do not render identically — it would fail against the old .ok().

Count 3 — "one end-to-end test passes without checking anything"; originally "the step passes unconditionally unless the output contains a particular phrase, and its partner step carries a matching escape hatch". RESOLVED. Both named steps now assert unconditionally, with inline comments recording why the guard was removed. I walked all 23 #[then] steps individually looking for any input under which no assertion runs — conditional asserts with no else, early returns, silently-falling-through matches, defanging unwrap_or — and found none.

The block is withdrawn on all three counts. It is replaced by the single new blocking finding above.

On the author's sweep claim. The claim that the diff was swept for this defect class beyond the reported sites, finding one further instance, does not hold: apps/rocmd/src/lib.rs:3239 is a fourth instance, in the same subsystem as count 1. A second, milder instance sits at apps/rocm/src/main.rs:18075, where the uninstall plan's remote-session warning is defaulted away on an I/O error — that one mirrors the pre-existing style on the line above it and is informational only, so it is not called out separately.

Why this will recur (and the cheap prevention). The cause is the codebase inviting the wrong conclusion, not reviewer error: load_managed_services(paths).unwrap_or_default() appears twice in this diff with identical shape, once feeding a security gate and once feeding a printed warning, and nothing at the call site distinguishes them. A competent reader sweeping for this class will keep classifying the security-gate use as benign best-effort, exactly as the author's sweep did. The prevention is one line: at :3239, use ? and add a comment saying this read may not be best-effort because its result arms the guard below — sitting next to the comment already there that explains why the flag must be restored at all.

On CI. The check-run conclusions at this head are counts only (failure 2, pending 4, skipped 1, success 21) with no lane names available, so no outcome is attributed to any named job here; the workflow observation above is read from the YAML, and I cannot confirm from this checkout whether any particular lane is red or why.

Check-run conclusions at this head were failure 2, pending 4, skipped 1, success 21 when the review started, and failure 2, pending 3, skipped 1, success 22 when this was filed. The earlier change request on this PR has been dismissed as superseded, so this is the only objection of ours that is live.

@juhovainio juhovainio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went through this pretty thoroughly given how security-sensitive it is (SSH as the control channel, credential handling, remote provisioning, the signing chain). Overall the design is careful and the corner cases are unusually well tested — most of what I went looking for to poke holes in turned out to already be handled and covered by a dedicated test.

I did find two real issues in the session cleanup/lifecycle code in remote/mod.rs, left as inline comments below. Neither is huge on its own, but both can end up destroying the only copy of an API key for a model that's still running and reachable on the tailnet — which, given the whole point of this feature is auth-gating that exposure, seems worth fixing before merge.

I also ran down the specific claims in the PR description, since they're the load-bearing ones and worth a human not having to re-derive:

  • The API key really does only travel over stdin. It never shows up in the ssh command line on this machine, and the remote reads it with read -r off stdin before anything execs — checked both directions.
  • --require-api-key is a hardcoded literal in the one function that builds the remote serve command, so there's no path where a remote session comes up without it.
  • The install.sh download-only / install-from-archive split does forward the checksum and signature, and the remote re-verifies both exactly like a normal install. One thing worth knowing, not a bug: on the nightly channel, signature verification is optional unless a key is explicitly configured — that's how install.sh already behaved before this PR, and it's called out in a comment, not something new here.
  • The signing-key env var resolution in provision.rs (_PATH before _PEM, empty treated as unset, a forwarded key blanking the remote's own _PATH) matches install.sh's own resolution logic exactly, which is the part that actually matters — a mismatch would mean the local and remote sides verify against different trust roots.
  • Ownership of a published tailnet port is established before the port is claimed and re-checked before it's torn down, with a test that specifically catches "the withdraw command exited 0 but the port is still published" — so teardown really is confirmed rather than assumed.
  • Passwordless sudo is checked (sudo -n true) before any privileged install command runs, ahead of the actual install call, so a machine that would prompt for a password fails fast with a clear message instead of hanging the SSH session. ROCm install also stays opt-in behind --install-rocm; the health-check path never triggers it on its own.

On CI: the two failing checks (E2E tests and E2E tests (rad3 R9700)) are both unrelated to this PR. The first is the known EAI-7960 dashboard flake already being fixed separately in #241, the second is a runner GPU-preflight/resource-contention failure. Neither traces back to this diff.

The PR is explicitly marked "ready for review, not for merge" with two items the author already flagged as needing a real tailnet/GPU to confirm, so I'm not re-flagging those — just the two cleanup-path bugs below.

Comment thread apps/rocm/src/remote/mod.rs Outdated
}
}

session::clear_key(paths, session_id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session::clear_key runs unconditionally here, even when leftovers isn't empty (i.e. the withdraw or services stop call just above it failed). That means when this cleanup path itself fails, the model can still be running and its endpoint can still be published on the tailnet, but the only copy of its API key just got deleted.

That's the opposite of the policy this same file uses a bit earlier for discover_started_service's failure path ("The key stays... deleting our only copy would leave a service the user can find but cannot call"). Can this gate on leftovers.is_empty() the same way, and report the key's path in the leftover message when it doesn't clear it? stop() already does the equivalent gating, so there's a pattern to follow here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ceff4f8f. clear_key is now gated on leftovers.is_empty(), and when anything could not be undone the key's path is appended to the leftovers instead of the key being deleted — so the message names it the way the discover_started_service branch already did.

You were right that there was a pattern to follow and this was the odd one out: discover_started_service keeps the key and says where it is, stop_with_transport gates on !force, and this was the only one of the three that deleted unconditionally.

Both directions are tested now: the existing both-steps-fail test asserts the key survives and that its path is reported, and a new test asserts a clean unwind still drops it, so the gate cannot drift into leaving orphaned credentials behind instead.

Comment thread apps/rocm/src/remote/mod.rs Outdated
// to every machine on the tailnet.
let session_id = RemoteSessionRecord::id_for(peer_host, request.remote_port);
let api_key = rocm_core::generate_endpoint_api_key();
session::store_key(paths, &session_id, &api_key).context(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session_id is deterministic from peer_host + remote_port, and remote_port defaults to a fixed value, so re-running serve against the same target/port (e.g. trying a different model on the same box before stopping the first one) silently overwrites the previous session's key here, and its record on success too.

If the new attempt then fails, the cleanup path a bit further down ends up deleting the key entirely (see the other comment), leaving the still-running earlier session both unreachable and untracked. This is the same "ownership before claim" property publish/withdraw enforce for the tailnet-forward layer — worth having the same guard here: check for an existing record at this session_id first, and refuse (or require --force) rather than overwriting silently.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ceff4f8f, and this one was worse than it looked — thank you for connecting it to the cleanup path, because the damage does not even need that path.

serve now establishes the name is free before it mints anything, the same way publish refuses a port that already forwards elsewhere. The check sits at the top of serve_with_transport, ahead of the readiness probe, so a refusal costs no round trip and cannot provision a machine the command then declines to use. It checks the key file as well as the record, since the two are written at different moments.

The plainer sequence, all defaults, was: serve again on the same box → the key file is overwritten before anything starts → the start fails because the first session still holds the port → the failure path deletes the shared key outright. The user reads "failed to start the model" as "nothing happened", while the first session is still serving on a published endpoint that can no longer be called.

Chasing it turned up a second problem in the remedy: a key with no .json record beside it cannot be reached by rocm remote stop at all, because load_all enumerates *.json only — so "stop it first" would have been advice that provably fails, on exactly the state the guard detects. The refusal now branches: with a record it points at stop; with a credential alone it says an earlier attempt got far enough to mint a key and not far enough to record what it started, and names the file. Tests cover both branches and assert nothing is sent to the machine before the refusal.

That in turn surfaced a third: rocm remote doctor reads an Examination, which carries platform, GPU, driver and ROCm facts but no services, processes or ports — so it can never answer "is a model running here". Two places were advising it for that question, including describe_leftovers. Both now advise ssh <target> -- rocm services list.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from b014f52 to 15e42f0 Compare September 14, 2026 18:26
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Pushed b014f52815e42f0f. Both inline findings are fixed and answered in their threads; this covers the rest of what changed, since it is more than those two.

Found while verifying the two reported bugs

  • provision.rs — the push-provisioning path never worked. ROCM_CLI_ARCHIVE was built by quoting {remote_dir}/{asset} as one value, and remote_dir is $HOME/.rocm/provision, deliberately left unexpanded for the remote shell. Single-quoting suppressed that expansion, so the remote looked for a file whose name literally began $HOME and failed at [ -f "${LOCAL_ARCHIVE}" ]. Only the asset name is quoted now. The old test asserted command.contains("ROCM_CLI_ARCHIVE="), which passed against the bug; the new one asks a real shell what the assignment evaluates to.
  • main.rs / rocmdrequires_api_key was derived from key-file presence, but resolve_endpoint_auth mints a key for every non-loopback bind whether or not auth was demanded. So a plain --host 0.0.0.0 --allow-public-bind recorded true, and since the guard tests that field before the bind address, those services were refused with a message naming a flag they never passed and a relaunch command that drops --allow-public-bind — coming back on loopback instead. The real flag is threaded through the spawn chain now, and the daemon's copy lost the same || key-file-present clause.
  • doctor.rs — suggested fix commands were interpolated into ssh <target> -- <command> unquoted. Most entries in the fix catalog contain a pipe, && or a redirect, so the command split across two machines: ssh box -- echo /usr/lib/wsl/lib | sudo tee /etc/ld.so.conf.d/wsl.conf runs the echo on the remote and the privileged tee locally. Rewriting is now done per Fix.commands entry rather than per rendered line, so multi-line entries stay whole, comment-only entries are not presented as commands, and the one stateful entry is not rewritten at all.
  • tailnet.rscheck: rocm remote doctor {peer.host} rendered a self-reported HostName as copy-pasteable text. Any device on the tailnet chooses its own, and nothing quoted it. Now quoted, with a test that asks a real shell what the rendered line passes along.
  • mod.rsresolve_peer returned the first of several exact matches while its own doc comment said ambiguity is an error; the loose path already refused. HostName is not unique on a tailnet. It now refuses and names the candidates by DNS name.
  • mod.rsobserve reported a machine that answered but whose command failed as Unreachable, rendering as "the machine could not be asked". It asks about publishing now, which is a fact it can still read.
  • expectation.rs, session.rs — three doc comments had rebound onto newly inserted items, leaving the items they described undocumented and Included carrying resolve's algorithm docs plus parameter names that are now fields.
  • run.sh — the credential-non-leak check read last-argv with || true, so an unreadable file yielded an empty string that trivially contains no key and the check passed having verified nothing. The e2e sibling did it correctly.
  • remote_steps.rsRemoteMachine::exec never checked exit status, and its caller asserts a port is absent from the serve config, which passes trivially on empty output.

Verification

cargo test --workspace --all-targets, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check and prek all pass. Every new test was checked by reverting its fix and confirming it fails — one had to be re-shaped after that check showed it passing against reverted code, and one needed a timeout because a fail-open guard does not return an error, it falls through and hangs.

Still unverified, unchanged from the description: no real tailnet has carried traffic, no real GPU has loaded a model, and nothing here has spoken to a live tailscale daemon.

Not addressed, and worth a decision

  • The remote control channel (containerised) lane is not in main's required checks and no job depends on it, so its checks cannot fail the build. Adding it needs the heavy gate moved from the job to its steps first — it is the only job-level one in the file, and a required check that never reports stalls the merge queue.
  • serve --require-api-key and services list --json are user-observable and have no Gherkin scenario. AGENTS.md accepts a written justification instead, but not silence.

@volen-silo
volen-silo dismissed stale reviews from siloteemu and juhovainio September 14, 2026 18:31

Addressed in 15e42f0 — see the inline replies and the summary comment.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 15e42f0

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds rocm remote: discover tailnet GPU machines over SSH, health-check and provision them, serve a model there, and publish its loopback port onto the tailnet via Tailscale — plus a containerised OpenSSH test lane and docs. Outcome: Needs work. Verified: ran one targeted check — the four new rocmd unit tests (cargo test -p rocmd --lib, 4 passed); the full workspace suite, the e2e suite and the container lanes were not run here. I confirmed by reading the live files that the previously-reported registry read is now propagated with ? rather than defaulted, that the API key is passed only on stdin and never interpolated into a command string, that SSH host-key checking is nowhere weakened, that install-side checksum verification is unconditional and precedes use of the archive, and that the Funnel fixtures use port 443 (a port Funnel can actually serve). No prompt-injection content was found anywhere in the changed files. The working copy under review was left unmodified (git status clean, HEAD unchanged); no experiment touched it. Check outcomes at review start: 25 success, 1 failure, 1 pending, 1 skipped. Blocking: 2 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

1. apps/rocm/src/remote/publish.rs:264 — a port already held by a Tailscale HTTPS/HTTP handler is classified as free, and publishing silently destroys it.

RawTcpHandler (publish.rs:108) deserializes only TCPForward. Tailscale's serve config represents a TLS-terminating handler as a TCP entry carrying HTTPS/HTTP with no TCPForward, so handler.and_then(|handler| handler.tcp_forward.clone()) yields None and classify returns PublishState::Absent — the same bucket as "nothing is there". publish treats Absent as free to take and issues tailscale serve --bg --tcp=<port> tcp://127.0.0.1:<remote_port>.

This is precisely the harm the module's own doc comment says must not happen: "tailscale serve overwrites whatever holds a port without complaint, so checking afterwards is too late... A second session reusing a port would silently take the first one's endpoint away." The Foreign state exists to refuse exactly this, and the HTTPS case walks straight past it. It is not theoretical: the default --tailnet-port is 8000 (remote/mod.rs:47), and tailscale serve --bg 8000 on that port is an ordinary thing for a user to have already done.

The added test an_https_handler_on_the_port_is_not_a_forward (publish.rs:556) asserts classify(r#"{"TCP": {"8000": {"HTTPS": true}}}"#, 8000, 11434) == PublishState::Absent, so it pins the wrong behaviour as correct. Its name and comment are literally true — an HTTPS handler is not our forward — but Absent does not mean "not our forward", it means "free to take", and that conflation is the bug.

Fix: parse HTTPS/HTTP (and ideally TerminateTLS) on RawTcpHandler, and classify "a handler exists at this port but is not our matching TCPForward" as Foreign { forwards_to: "an existing HTTPS/HTTP handler" } in all three nestings (TCP, Foreground, Services). Update the test to assert Foreign, and correct the SERVE_CONFIG_KEYS doc comment at publish.rs:40-48, which currently states the HTTPS/HTTP handler is never read because "this design never asks Tailscale to terminate TLS on our behalf" — true of what we create, irrelevant to what someone else already created.

2. apps/rocmd/src/lib.rs:3260 — the call-site test claims to catch miswiring of the API-key guard, but only one direction of miswiring can fail it.

The fix itself is correct: load_managed_services(paths) is now propagated with ? instead of .unwrap_or_default(), the key-file fallback clause is gone, and record.requires_api_key is restored from the registry before the guard runs. I verified this in the live file and ran the four new tests; they pass at this head.

The problem is the coverage claim. The helper's doc comment (lib.rs:5299) states it exists because a literal-argument unit test "cannot catch the defect that actually happened twice in this crate's history — the guard being wired up with the wrong value at its call site". Only the false direction is defended. Mutate line 3260 to record.requires_api_key = true; — hardcoding the requirement rather than reading it — and all four new tests still pass, because no test drives supervise_service for a service that never required a key. That mutation is not hypothetical: the comment immediately below line 3260 records that an over-broad requirement (the || key-file-is-present clause) already shipped once in this PR and "refused them with the wrong remediation". The remediation removed the defect but shipped no test that would stop it returning.

Fix: add a third case using the existing seed_registry / supervise_at_the_guard helpers — seed a record with requires_api_key: false and no key file, and assert supervise_at_the_guard does not refuse. While there, also mutation-proof the existing.service_id == record.service_id half of the .any(...) predicate: with a single seeded record, dropping that comparison changes nothing, so a second service's requirement leaking onto an unrelated one is currently undetectable. Seeding two records (one requiring a key, one not) covers both in a single test.

Non-blocking

  • apps/rocm/src/remote/transport.rs:457 — the explained_by_the_command demotion is never exercised: every test that reaches the written match uses a destination that exits 255, which returns earlier, so dropping && !output.status.success() (the fail-open direction, which would let a truncated API-key write read as success) breaks no test.
  • apps/rocm/src/remote/doctor.rs:154is_stateful_sequence is a live path with no coverage; forcing it to return false leaves all 16 module tests green, so a broken guard would silently split a multi-step fix that depends on a shared subshell.
  • apps/rocm/src/remote/mod.rs:567remote_serve_command puts the positional model before the flags with no -- separator, so a flag-shaped model name is parsed as a flag by the remote's rocm serve; it fails closed, but the error misattributes the cause.
  • .github/workflows/ci.yml:669 — the new lane gates at job level (if: needs.changes.outputs.heavy == 'true') unlike every sibling job here, which gate per step; harmless today, but it would stall the merge queue on a non-heavy PR if this check ever becomes required.
  • tests/e2e-cucumber/features/remote.feature — nothing in the suite makes a request against a published endpoint, so the --require-api-key invariant is asserted only as a string in CLI output, never at the traffic level; likely inherent to the stand-in remote, but worth stating in the PR text as a named gap.

On the standing objection: the single count filed at the earlier commit — the API-key requirement being silently dropped and written back to disk when a registry read failed — is resolved, verified against the live code rather than taken on the author's account. The registry read now propagates instead of defaulting, the key-file-presence fallback is gone, the requirement is restored from the registry alone, and the guard runs before the record is written; both requested tests exist and drive the real call site. That change request has been dismissed as superseded, so the two findings above are the only objections of ours that are live. Blocking item 2 is a narrower successor, not a restatement.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 15e42f0 to 357130b Compare September 15, 2026 07:07
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Pushed 15e42f0f357130b5. Both blocking findings are fixed.

1 — a port held by an HTTPS/HTTP handler read as free. Correct, and the test was pinning it. RawTcpHandler now parses HTTPS, HTTP and TerminateTLS alongside TCPForward, and classify treats any handler at the port that is not our matching forward as Foreign, naming what holds it so the refusal tells the user what they would have overwritten. A handler kind this CLI does not recognise refuses too — a newer Tailscale adding one must not read as an empty port. classify already resolves the handler across TCP, Foreground and Services before that point, so all three nestings are covered by the one change.

The test is rewritten to assert Foreign across all four handler shapes, plus the converse: a handler on a different port is still Absent, so the guard has not become "refuse whenever the document mentions a port". A second test drives publish end to end against a held port and asserts no serve --bg was issued.

The SERVE_CONFIG_KEYS doc comment is corrected, for the reason you gave — the HTTPS/HTTP fields are read not because this design ever terminates TLS, but because someone else's handler still occupies the port.

2 — the call-site test only defended one direction. Also correct. Added a case seeding two records, one requiring a key and one not, so the existing.service_id == record.service_id half of the predicate is load-bearing rather than decorative.

Worth recording how that went, because the first attempt failed in the way your finding predicts. I asserted on the service manifest appearing, as proof the guard had admitted the call — and both mutations you named survived it, because the seeding helper had already written that manifest, so the poll proved nothing. The working version decides on what the call returns, identifying the guard's refusal by its message rather than by the bare fact of an error. Confirmed by applying both mutations: each fails the test now, and it passes unmutated.

One more in the same area, not reported, found while acting on the first finding. fix-10-container stores a single docker run as seven Fix.commands entries, each ending in a trailing \. Rewriting per entry — correct for fix-1-arch, which is one entry with an embedded newline — turned it into seven independent ssh calls, each carrying a dangling backslash inside its quoting. Continued entries are now merged before anything rewrites them, so both storage shapes become one whole command first.

Also corrected a comment in provision.rs asserting that install.sh only requires a signature on the release channel. resolve_public_keys falls back to the pinned release key with no channel argument, so public_keys is non-empty on every channel and the || [ -n "${public_keys}" ] arm forces verification regardless of the channel-gated require_sig above it.

Non-blocking items: none addressed in this push, and taking them in turn rather than as a block — the explained_by_the_command demotion and the is_stateful_sequence coverage gaps are both real and both worth closing; remote_serve_command's missing -- is real and does fail closed as you say; the job-level heavy gate is real and is a prerequisite for making that lane a required check, which is a separate decision; and the traffic-level --require-api-key gap is inherent to the stand-in remote, so it belongs in the PR text as a named gap rather than behind a scenario that would only assert on CLI output.

Verification: cargo test --workspace --all-targets, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check and prek all pass.

@volen-silo
volen-silo dismissed siloteemu’s stale review September 15, 2026 07:11

Addressed in 357130b — see the reply comment.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 357130b to 10aaa17 Compare September 15, 2026 08:05
@siloteemu

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · 357130b

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Scope note: this review was made against commit 357130b5, which was the head when it started. The branch has since moved on, so this is an observation about that commit and not a verdict on the current head. No change request is being filed on the strength of it; the newer commits will be looked at on their own terms.

Summary

Adds rocm remote — tailnet GPU discovery over SSH, health checks, provisioning, remote serving, and publishing the model's loopback port onto the tailnet — plus a container test lane and docs. Needs work: one test does not test what it names, everything else in a 31-file review came back clean. Verified: on a scratch copy outside the checkout I replaced RemoteSessionRecord::write's staging-file+rename with a plain fs::write to the destination and ran cargo test -p rocm --bin rocm remote::session — 9 passed, 0 failed, including a_record_is_replaced_atomically_so_a_reader_never_sees_half_of_one, so that test survives full removal of the atomicity mechanism it is named for. I also confirmed against the code that the API key is delivered on SSH stdin and never appears in the remote command string (so it is absent from ps and shell history), that production SSH never weakens host-key checking (no StrictHostKeyChecking=no/UserKnownHostsFile=/dev/null outside test fixtures), that checksum and signature verification in install.sh runs on both halves of the split download/install path before anything is executed, that the published forward is tailnet serve and never funnel with the model bound to 127.0.0.1 behind a required API key, and that every command, flag and security claim in the README and docs/testing.md matches the clap definitions and the harness. Both previously-filed counts are fixed at this head and the fixes are test-enforced: the port-classification lead now parses HTTPS/HTTP/TerminateTLS in all three nestings and classifies a non-matching handler as foreign, and the API-key call-site lead now has a two-record test that goes red both when the call site is hardcoded to true and when the per-service id comparison is dropped (both mutations run and confirmed red). Check runs at this head: 13 success, 11 pending, 1 neutral, 1 skipped — no failures, but most lanes had not reported when this review was made, so nothing here rests on CI. The full suite, the e2e suite and --all-targets were not run here. I confirmed every file in git diff prw-base...HEAD --name-only landed in a reviewer's scope — all 31, with the provisioning cluster re-dispatched after its first pass stalled, and nothing assumed covered by a sibling. The checkout was left unmodified (git status clean, HEAD still 357130b); all mutation work was done on a copy elsewhere. Blocking: 1 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

apps/rocm/src/remote/session.rs:369-395a_record_is_replaced_atomically_so_a_reader_never_sees_half_of_one cannot fail for the defect it names.

The test's comment states the guarded failure is a torn record that load_all skips, "which hides a session whose model is running and whose endpoint is published — the one thing that must never happen quietly." But the test performs two sequential writes and then asserts only that (a) exactly one record exists with the second write's model, and (b) no *tmp* file is left in the directory. Neither assertion involves concurrency, a partial write, or a reader observing an intermediate state.

Replace the whole mechanism at session.rs:125-132 — the path.with_extension(format!("{}.{}.tmp", ...)) staging file, the fs::write(&staging, ...), and the fs::rename(&staging, &path) — with a single fs::write(&path, &bytes), and the test still passes: the second sequential write still leaves the correct final content, and no staging file is ever created, so the leftover check trivially holds. I ran exactly this mutation on a scratch copy; all 9 remote::session tests passed. CI therefore certifies an atomicity guarantee no test exercises, and a future refactor that drops the rename would go unnoticed.

Fix: make the test observe the intermediate state. Either spawn two writer threads racing on the same session_id alongside a reader thread that asserts every observation is well-formed JSON matching one of the two writes (never a truncated blob), or — cheaper and deterministic — assert the staging path itself: write a record, and in the same test assert that a plain fs::write to a same-named destination while a partial file exists is never what load_all observes, by pre-creating a truncated <id>.json and proving a subsequent write() replaces it wholesale rather than appending into it. At minimum, add an assertion that a staging file with the {pid}.{millis}.tmp shape is created during the write (via a test seam), so deleting the rename step turns the suite red. Until then the test should not carry atomically in its name.

Non-blocking

  • apps/rocm/src/remote/mod.rs:224 — the comment says "the assistant path allowlists this command as read-only" without naming which binary; the allowlist is in apps/rocmd/src/lib.rs:2527, and this ambiguity sent a reviewer here to the wrong file and the wrong conclusion. Name the binary.
  • apps/rocmd/src/lib.rs:2527 vs apps/rocm/src/main.rs:12045 — the daemon now admits remote targets|doctor|status as read-only, but the CLI's own chat classifier has no remote arm and rejects it as unsupported; the adjacent setup arm's comment claims the two allowlists are mirrored across binaries, so this PR introduces a divergence from a stated invariant.
  • apps/rocm/src/main.rs:444--api-key's help still says "Ignored for loopback binds, which stay credential-free", which stops being true once the new --require-api-key is passed (resolve_endpoint_auth at 5618 short-circuits only when !required).
  • apps/rocm/src/remote/session.rs:125 — the staging suffix {pid}.{unix_millis}.tmp can collide between two threads in one process writing the same session id within a millisecond; add a counter or thread id.
  • tests/e2e-cucumber/src/bin/fake-tailscale.rs and tests/remote-ssh/fake-tailscale.sh — the two stand-ins deliberately cover disjoint subcommands (local status vs remote serve/funnel), which reads as drift without a cross-reference comment in each; they also name the same concept differently (FAKE_TAILSCALE_STATUS vs FAKE_LOCAL_TAILSCALE_STATUS).

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 10aaa17 to 9c8edd9 Compare September 15, 2026 08:32
@siloteemu

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · 10aaa17

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Scope note: this review judges commit 10aaa172 only. A newer commit (9c8edd95) was pushed while it was running, so this is not a verdict on the current head, and no review gate has been filed against it. The finding below was re-confirmed by execution at 10aaa172; it should be re-checked against the newer commit.

Summary

Adds rocm remote — serving a model on a remote GPU machine over SSH and publishing it to a tailnet — plus a containerised SSH harness, cucumber scenarios and docs. Needs work: one carried-over test-quality blocker is now confirmed by execution, everything else is a coverage or consistency gap. Verified: seven branch-level mutations run in a scratch copy against the two narrow unit modules they touch (remote::session::, remote::publish::, remote::doctor::, rocmd --lib) — six were caught, one survived (the atomic-write test below); the full suite, e2e and clippy were not run here. Also confirmed at this head, on the live code: the endpoint API key is minted, stored 0600 and piped on stdin, never appearing in the remote command string, argv or any error/log text (mod.rs:567-582, transport.rs stdin test); StrictHostKeyChecking/UserKnownHostsFile weakening exists only under tests/, never in apps/ (transport.rs:256-270 sets only BatchMode, ConnectTimeout, -p); checksum verification is unconditional and signature verification is forced on the release channel, both strictly before extraction or download-only exit (install.sh:412-460); the forward is hard-bound to 127.0.0.1 with --require-api-key appended unconditionally, and classify refuses to act on any AllowFunnel state (publish.rs:55,236-322,369-501); and every user-controlled value reaching a shell passes through shell_quote, with transport.rs:187-227 additionally rejecting hyphen-leading destinations. The author's status comment describes a push to 357130b, which is not reachable in this checkout, so I could not isolate what changed after it and reviewed the whole diff afresh instead — every factual claim in that comment that I could test against this head held up (all four handler shapes plus the converse are pinned; the held-port publish test is not vacuous; the two-record seeding makes the service_id conjunct load-bearing; the continuation merge is caught by its test; the provision.rs comment about install.sh:431 forcing verification regardless of channel is accurate). CI at this head: 19 success, 1 failure, 1 skipped — there is a failing check, and with no per-lane detail available I cannot attribute it to a named job. The checkout was left unmodified (git status clean; all mutation work was done in a separate scratch copy, also left clean). Blocking: 1 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

apps/rocm/src/remote/session.rs:371-397a_record_is_replaced_atomically_so_a_reader_never_sees_half_of_one cannot fail for the defect it names.

The test's name and its own leading comment claim it guards atomicity ("a torn record is skipped by load_all, which hides a session whose model is running and whose endpoint is published — the one thing that must never happen quietly"). It does not. Replacing the staging-file-plus-fs::rename sequence in RemoteSessionRecord::write (session.rs:108-133) with a plain fs::write(&path, &bytes) — deleting the atomicity mechanism outright — leaves the whole module green: cargo test -p rocm --bin rocm remote::session:: returned 9 passed; 0 failed with the mutation applied.

Why it passes regardless: the test writes twice, sequentially, in one thread. Nothing races and nothing is interrupted, so no torn file is ever produced. Assertion 1 (loaded.len() == 1) and assertion 2 (loaded[0].model == "a-different-model") hold for any correct overwrite, atomic or not. Assertion 3 scans for filenames containing "tmp" — under the mutation no staging file is ever created, so it passes vacuously. What the test actually proves is overwrite-by-deterministic-id, which is real and worth keeping, but it is not atomicity; CI currently certifies a guarantee nothing exercises.

Concrete fix — either of:

  • Keep this test but rename it to what it checks (e.g. rewriting_a_session_replaces_the_record_rather_than_accumulating_one), drop the atomicity language from its comment, and add a separate test that can actually fail: plant a stray <id>.<pid>.<ts>.tmp file containing invalid JSON directly in the sessions directory and assert load_all still returns the real record and does not warn on it; plus a concurrency test spawning two threads calling write for the same id in a loop and asserting every interleaved load_all observation parses as complete, valid JSON.
  • Or drive a fault through the mechanism: make the rename fail (e.g. by pre-creating a directory at the record path) and assert the error surfaces and the staging file is cleaned up — that branch at session.rs:129-133 is likewise unexercised today.

The whole point of the surrounding design comment is that a half-written record silently hides a live, published endpoint. That is exactly the failure this test is the only guard against, and it is not guarding it.

Non-blocking

  • apps/rocm/src/remote/mod.rs:396-442 — the two unwind branches in serve_with_transport (publish failure, record-write failure) are never driven end-to-end; unwind_partial_serve is well tested directly with hand-fed arguments, but deleting the if let Err(...) = publish::publish(...) block would compile and leave the suite green, silently proceeding to print success over a model holding a GPU with no endpoint.
  • apps/rocm/src/remote/doctor.rs:200-204is_stateful_sequence has no test at all; mutating it to always return false survives the module, and the consequence is a multi-step subshell fix rewritten into independent ssh calls that look runnable and are not.
  • tests/e2e-cucumber/tests/e2e/remote_steps.rs:663-668 — remote-12's "endpoint is restored" Then matches CLI stdout only, while the sibling then_machine_publishing already has a container-side oracle; the behaviour itself is genuinely pinned by attach_republishes_a_healthy_session_whose_endpoint_went_missing (mod.rs:1932), so this is an inconsistency rather than a hole.
  • .github/workflows/ci.yml:669 — the new remote-ssh job puts the heavy check in its job-level if, which this file's own comments warn against three times (verbatim, around line 1061: "Gating heavy at the job level would SKIP the job on a non-heavy PR, and a required check that is never produced stalls the merge queue"); harmless today, but a prerequisite to fix before the lane can be made required.
  • apps/rocm/src/remote/provision.rs:39INSTALLER_URL is hardcoded to the public upstream main, ignoring the repo/mirror overrides install.sh itself supports, and the remote $HOME/.rocm/provision staging directory is never cleaned up after a successful install (the local side is, via Drop).

@juhovainio juhovainio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read through the new rocm remote subsystem (discovery, provisioning, serve, publish/teardown) plus the touch-points on existing serve/services list/install.sh, using a full checkout of the branch rather than just the diff so I could trace the surrounding code.

Overall this is solid work — the trust-chain, injection-safety, and backwards-compatibility claims in the PR description all held up when I actually read the code, not just the comments. I found two real bugs worth fixing before merge, plus a handful of smaller things. Left as inline comments below.

The two that matter:

  • An unattended remote install can proceed on a platform the failure-mode catalog has never evaluated, because install::assess() only looks at matched findings and never checks out_of_scope.
  • A race in the session-key file writes: two concurrent rocm remote serve calls against the same target+port can both pass the exists() check, and the loser's cleanup path can delete the winner's key out from under a live, published session.

Everything else is minor: a couple of TOCTOU-shaped gaps that are narrow but real, a shell command that depends on ; behaving like && (already flagged as fragile in the code's own comment), a remote staging directory with no explicit permissions, and two CI gating nits on the new remote-ssh job.

One thing I want to call out as not a problem, since the PR explicitly raises it: the FunnelAllowed guard being unreachable at the default port 8000 is correctly implemented and not a bug — it's a real design question (document it / change the default / drop it) rather than something to fix in code.

///
/// Split from the doing so the judgement is testable on its own — the part
/// worth being sure about is what gets refused, not what gets run.
pub(crate) fn assess(report: &DiagnoseReport, passwordless_sudo: bool) -> Option<Refusal> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: assess() only inspects report.matched, never report.out_of_scope. rocm_core::diagnose deliberately distinguishes "nothing wrong" from "this platform isn't covered by the catalog" — but here they're treated the same. On an out-of-scope platform with passwordless sudo available, this returns None and the install proceeds unattended on a machine the catalog admits it never evaluated. That directly undercuts the stated goal of this module ("installing ROCm is opt-in and gated on the failure catalog"). Also untested — the shared test helper hardcodes out_of_scope: None. Low exposure today since install.sh only supports Linux/x86_64, but the gate should still check out_of_scope.is_some() and refuse.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b2c5dd94.

Worth noting before the detail: this thread's anchor has drifted. These are new files, so the whole file is one addition hunk and GitHub re-anchors a comment by offset rather than marking it outdated — the line this comment now sits beside is not the line it was written about. The code you quoted has changed.

assess() now asks out_of_scope first, before findings and before sudo, and returns a new Refusal::NotEvaluated carrying the catalog's own sentence.

Your diagnosis was right and the reason it is easy to miss is worth recording: diagnose() returns an empty matched in two opposite situations — a machine that was checked and is clean, and a platform with no catalog entries, where nothing runs at all (diagnose.rs forces matched empty whenever out_of_scope is set). One is a verdict, the other is the absence of one, and only out_of_scope separates them. rocm-core says as much in prose: "nothing was checked -- this is not a clean bill of health."

Checked first rather than last because the ordering is itself a claim: fixing sudo does not make an unevaluated platform installable, so a sudo refusal would send the user to do work that cannot help.

On the test gap you flagged — covered at two levels. At the guard, by running the real diagnose() against an examination the catalog has no checkers for, rather than hand-building a report with out_of_scope: Some(..); what makes a platform out of scope is the catalog's coverage rule, and a hand-built report would assert our idea of that rule instead of the catalog's. At the call site, by driving ensure_ready_with(.., install_rocm = true) and asserting the transport is never asked to run install driver — the guard returning a refusal is the outcome, but not issuing the install is the mechanism.

/// The key is checked as well as the record, because they are written at
/// different moments: a session that failed between minting its key and writing
/// its record leaves the key alone on disk.
pub(crate) fn exists(paths: &AppPaths, session_id: &str) -> bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: exists() (checked before a session claims a target+port) and the later write are not atomic — no O_EXCL, just create+truncate. Two concurrent rocm remote serve calls against the same target+port can both pass this check and then both write/overwrite the key file. If the loser's port-claim then fails, its cleanup path (clear_key, further down this file) deletes the key file outright — which can be the winner's key if the write race went the other way. publish.rs explicitly re-verifies ownership after a destructive/claiming action; this file doesn't apply that same recheck to the key file, so a live, published session can silently lose its own API key.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b2c5dd94. Same caveat as the other thread: these are new files, so GitHub re-anchored this comment by offset rather than marking it outdated — the code beside it now is not the code it was written about.

You were right, and the window is wider than the description suggests. exists() is called before bootstrap::ensure_ready_with, deliberately, so a refusal costs no round trip — but the key write happens after provisioning, which the module's own docs describe as taking minutes when it installs a CLI. So the gap between check and write is not a few instructions, it is the entire readiness probe.

The fix makes the credential write be the claim rather than adding another check: store_key now creates with create_newO_EXCL on unix, CREATE_NEW on Windows — with 0o600 applied at creation rather than tightened afterwards. One syscall decides ownership, so there is no second step to race and no lock artefact that could itself leak.

Your point about the loser's cleanup is what shaped the error handling. A bare boolean would have lost the distinction between "the name is taken" and "the disk is full", so the failure carries a distinct NameAlreadyHeld that the call site downcasts: on that path nothing of ours is on disk, so serve bails with no unwind and — the part that matters — no clear_key, because the credential belongs to the winner.

exists() is kept, but its doc comment now says it diagnoses rather than decides. It earns its place by being cheap and by telling a recorded session from a stray credential, which need different remedies.

On testing it: an end-state assertion cannot see this defect, because both runs write to the same name and an overwrite leaves an identical filesystem — which is why the previous test here passed with the mechanism absent. The assertion is on return values instead: sixteen concurrent serves, exactly one Ok. That holds under every interleaving and is only true when the claim is indivisible.

// port, not a forward, so it would happily tear down whatever is on that
// port — including something another tool or another person put there after
// our session was recorded.
match publish_state(transport, tailnet_port, remote_port)? {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: ownership is checked once here, but the actual teardown (transport.exec(&withdraw_command(...)), a few lines down) isn't re-checked immediately before it runs. tailscale serve ... off acts on the port, not a specific forward, so if a third party republishes something else to this port in that narrow window, this still tears it down and reports Ok. Narrow, but the check-then-act gap is real given how carefully the rest of this function documents wanting to avoid exactly that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressed yet. Agreed the check-then-act gap is real and narrow: tailscale serve ... off acts on the port, so a third party republishing in that window gets torn down and reported Ok.

Worth noting the same function already re-reads state after the withdraw to confirm it, so the shape for a pre-teardown recheck is there. The honest limit is that a recheck narrows the window rather than closing it — only the daemon could make it atomic — so it is a real improvement and not a fix.

/// anyway, since the publish widens who can reach it.
fn remote_serve_command(remote_cli: &str, request: &ServeRequest) -> String {
let mut command = format!(
"IFS= read -r ROCM_SERVE_API_KEY; export ROCM_SERVE_API_KEY; \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this command joins IFS= read -r ..., the export, and rocm serve with ;, not &&. The broken-pipe-suppression logic in transport.rs (search run_with_piped_io) explicitly says its safety depends on the read running first and the command's exit status reflecting that — but with ;, the compound's exit status is whatever rocm serve returns, not the read. The code comment there already flags this coupling as something to recheck if this command is ever reordered; swapping to && would let the shell itself enforce the invariant instead of relying on downstream validation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressed yet, and I think this is the least minor of the five.

You are right that the coupling is real and currently held by prose. run_with_piped_io demotes a BrokenPipe only when the command also failed, and its comment states the dependency explicitly: the read runs first, so a broken pipe means the read never finished, which means the command cannot have exited 0. With ; the compound's status is whatever rocm serve returns, so the shell is not enforcing what the comment claims — the ordering is.

&& would move that invariant from a comment into the shell, which is the right direction. Holding off only because it changes the remote command shape and the container lanes assert on it; it should land with a test that observes the exit status rather than the string.


let remote_dir = REMOTE_STAGING;
transport
.run(&format!("mkdir -p {remote_dir}"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the remote staging directory is created with plain mkdir -p, no explicit mode. The local counterpart (search create_restricted_dir) is a carefully-justified nonce-named 0700 directory specifically to avoid another user on the same box reading or racing the pushed archive/checksum/signature before verification. Worth the same discipline server-side, especially on a shared remote box with a permissive umask.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressed yet, and the asymmetry you point at is the argument for it: the local side goes to some trouble with a nonce-named 0700 directory precisely because another user on the box could race the artifact before verification, and the remote side has the same exposure with none of the care.

Worth pairing with the mode being set at creation rather than tightened afterwards, since mkdir -p then chmod leaves the same umask window the local helper avoids.

Comment thread .github/workflows/ci.yml
# No GPU, no ROCm, no tailnet: the remote's `rocm` and `tailscale` are
# stand-ins that answer in the shapes the real tools do.
name: remote control channel (containerised)
runs-on: ubuntu-latest

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: every other job gated on heavy in this workflow (build-and-test, test, windows-build-and-test) has needs: [changes, clippy, prek], specifically so a fast lint failure short-circuits before spending build time. This new job only has needs: changes, so it'll run its full 30-minute container+cargo cycle even when clippy or prek would have failed instantly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressed yet. Confirmed: every other heavy-gated job carries needs: [changes, clippy, prek] and this one has only needs: changes, so it burns the full container and cargo cycle on runs a lint failure would have ended in seconds.

This is also a prerequisite for the other open question on this lane — whether it should be a required check — because the gating has to be right before anything depends on it.

Comment thread .github/workflows/ci.yml
# stand-ins that answer in the shapes the real tools do.
name: remote control channel (containerised)
runs-on: ubuntu-latest
timeout-minutes: 30

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the other heavy-gated jobs in this file also carry if: github.event_name != 'workflow_dispatch', so a manual dispatch stays a fast loop. This job doesn't have that guard, so a manual dispatch will now also pull in this 30-minute containerized job.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressed yet, and it belongs with the needs: change above rather than separately — both are about this job not matching the conventions of its siblings in the same file.

@siloteemu

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · 9c8edd9

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds rocm remote — serving a model on a tailnet GPU machine over an SSH control channel, with a Gherkin feature, a containerised SSH harness and a new CI lane. Outcome: Needs work — the production code held up well under adversarial review, but three tests do not constrain the branch they name. Verified: in a scratch copy, four branch-level mutation rounds of cargo test -p rocm --bin rocm filtered to the remote:: modules; the previously blocking atomicity gap is now genuinely caught (deleting the staging-file-plus-rename makes replacing_a_record_swaps_the_file_rather_than_rewriting_it_in_place fail — 10 passed, 1 failed, versus 9 passed 0 failed before), while deleting the peer sort, the clear_key id guard and the validate_destination leading-dash guard each left every one of the 156 remote:: tests green; re-confirmed on the live code that the API key reaches the remote only on stdin and never in argv or a command string, that no production path weakens SSH host-key checking, that checksum and signature are verified before the artifact is extracted or installed, that the endpoint is refused rather than published when Funnel is allowed and an API key is minted unconditionally, and that user-controlled values are shell-quoted. The full suite, the e2e suite and clippy were not run here. CI at this head: 18 success, 1 failure, 1 pending, 1 skipped — there is a failing check, and with no per-lane detail available I cannot attribute it to a named job, so nothing below rests on it. Blocking: 3 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

apps/rocm/src/remote/tailnet.rs:452parsing_orders_peers_and_strips_the_magicdns_trailing_dot does not test the ordering it names.
parse_status deserialises peers into a BTreeMap<String, RawPeer> keyed by node public key (tailnet.rs:136) and then explicitly sorts by host name (tailnet.rs:225-230). The fixture's keys are nodekey:aaagpu-box-1, nodekey:bbbgpu-box-2, nodekey:cccphone, so BTreeMap iteration already yields exactly the expected ["gpu-box-1", "gpu-box-2", "phone"]. I deleted the sort_by call outright in a scratch copy: all 15 remote::tailnet tests still passed, including this one. The sort is correct production code guarding against unordered output, and nothing pins it. Fix: invert the fixture so key order and host order disagree — map nodekey:aaa to phone and nodekey:ccc to gpu-box-1, leaving the expected vector unchanged. Add a one-line comment saying the keys are deliberately in the opposite order to the hosts, so the next editor does not "tidy" the fixture back into agreement and silently re-blind the test.

apps/rocm/src/remote/session.rs:535an_unusable_id_cannot_place_a_credential_outside_the_sessions_directory asserts nothing about the behaviour its comment claims to catch.
clear_key guards a path-traversal by refusing ids that fail validate_id (session.rs:210-213). The test calls clear_key(&paths, "../../escaped") under the comment "And clearing one is a no-op rather than a delete somewhere else" and then makes no assertion at all — clear_key returns () and swallows the I/O result, so the line passes vacuously. I deleted the validate_id guard in a scratch copy, making clear_key unconditionally fs::remove_file a traversed path: every remote:: test still passed. This guards deletion of a file outside the sessions directory via an attacker-shaped session id, so it must be pinned. Fix: plant a file at the resolved escape target, call clear_key with the traversal id, and assert the planted file still exists.

apps/rocm/src/remote/transport.rs:626a_name_ssh_would_read_as_an_option_is_refused leaves half of the guard it names unpinned.
validate_destination refuses destination.starts_with('-') || host.starts_with('-') (transport.rs:192), where host is the part after the last @. Every hostile case in the test (-oProxyCommand=touch /tmp/pwned, --fake, user@-oX, "") is caught by the host disjunct alone, because each either contains no @ or puts the hyphen after it. I deleted the destination.starts_with('-') disjunct in a scratch copy: all 156 remote:: tests still passed. The shape that needs the deleted disjunct — -oProxyCommand=x@host, whose host half is clean but whose whole token ssh reads positionally as an option — is exactly the local-command-execution case the doc comment above the function describes. Fix: add "-oProxyCommand=touch /tmp/pwned@host" to the hostile list.

Non-blocking

  • apps/rocm/src/remote/provision.rs:166-180 — no test drives push_matched_artifact, so deleting the .sha256/.sig sidecar pushes leaves all 15 remote::provision tests green (confirmed by mutation); the remote installer fails closed on a missing checksum, which is why this is not blocking, but the comment's "splitting the trust chain across two machines must not shorten it" is currently unenforced.
  • apps/rocm/src/remote/publish.rs:681ports_are_matched_as_string_keys_not_numbers cannot distinguish a string-vs-numeric key regression from any other lookup mismatch.
  • apps/rocm/src/remote/transport.rs:51exit_label's signal branch (code: None) is never exercised, so swapping the two arms would go unnoticed.
  • tests/remote-ssh/Dockerfile:1alpine:3.20 is a floating tag; the repo already requires SHA-pinning for Actions, and digest-pinning the test base image would match that intent.
  • Prior non-blocking items at the preceding commit: the serve_with_transport unwind branches are now driven end to end by dedicated tests and that item is resolved; is_stateful_sequence is still untested, remote-12's endpoint-restored step still matches stdout only (defensible — publish re-queries and bails unless the remote confirms), INSTALLER_URL is still hardcoded to a public HTTPS path with the remote staging directory still uncleaned, and the new CI lane's gating now reads consistently with the harness scripts.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 9c8edd9

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

First, the previous round's blocking finding is genuinely resolved at this head, and confirmed rather than taken on trust: deleting the staging-file-plus-rename now makes the replacement test fail, where before the same mutation left the module entirely green. That objection is closed.

The production code held up well under adversarial review at this head. Three new counts, all of the same shape — a test that does not constrain the branch it names, each confirmed by deleting that branch and watching the suite stay green. Two of the three guard security properties. The full report, including five non-blocking observations, is in the review comment on this PR.

1. The peer-ordering test cannot observe the sort it is named for. Parsing collects peers into a map keyed by node public key and then sorts them by host name. In the fixture, the keys and the host names happen to rise together, so map iteration alone already yields the expected order. Deleting the sort call outright left all fifteen tests in that module passing. The sort is correct production code guarding against unordered output, and nothing pins it. Inverting the fixture so key order and host order disagree — while leaving the expected vector unchanged — makes the test real. Please add a line saying the keys are deliberately in the opposite order to the hosts, so a later editor does not tidy the fixture back into agreement and silently re-blind it.

2. The path-traversal test for credential clearing asserts nothing. The clearing helper refuses ids that fail validation, which is what stops an attacker-shaped session id deleting a file outside the sessions directory. The test calls it with a traversal id under a comment saying clearing one is a no-op rather than a delete somewhere else, and then makes no assertion at all — the function returns unit and swallows the I/O result, so the line passes vacuously. Deleting the validation guard, so the helper unconditionally removes the traversed path, left every test in the module passing. Plant a file at the resolved escape target, call the helper with the traversal id, and assert the planted file still exists.

3. The destination-refusal test leaves half of its guard unpinned. The check refuses a destination whose whole token begins with a hyphen, or whose host half does. Every hostile case in the test is caught by the host-half disjunct alone, because each either contains no separator or puts the hyphen after it. Deleting the whole-token disjunct left all 156 tests in that area passing. The shape that needs it is a destination whose host half is clean but whose whole token is read positionally as an option — which is precisely the local-command-execution case the function's own doc comment describes. Adding one such case to the hostile list closes it.

Each of these gates rather than sitting in the non-blocking list for the same reason: a weak test is not strengthened after merge, and in counts 2 and 3 the branch left unpinned is the one carrying the security property. The mechanisms themselves are implemented correctly today; the objection is only that nothing would notice if they stopped being.

Check-run conclusions this review worked from, at this head: 18 success, 1 failure, 1 pending, 1 skipped; re-read immediately before filing, the pending had resolved to success, giving 19 success, 1 failure, 1 skipped — the failure count is unchanged. Conclusion counts only; no per-lane detail was available, so nothing here is attributed to any named job, no claim is made about what the failure is, and nothing above rests on it.

Adds `rocm remote`: discover GPU machines on a tailnet, check their
health, install what they are missing, serve a model on one, and reach it
from any machine on the tailnet.

SSH is the control channel, not the data path. Everything that inspects
or changes the remote goes over SSH; the inference traffic does not.
`rocm serve` binds loopback on the GPU machine as it always has, and the
machine then tells its own Tailscale daemon to forward a tailnet port to
it. Nothing runs locally, so the endpoint outlives the command that
created it and answers from any machine rather than only the one that
started it.

Two touch points with existing behaviour:

- `rocm serve --require-api-key` makes a loopback bind authenticated
  anyway. Publishing the port makes "loopback means only this machine"
  false while leaving the bind address unchanged, which would otherwise
  put an unauthenticated model endpoint on the tailnet. The key travels
  to the remote on stdin, never in a command line, since both machines
  expose command arguments in their process tables.
- `install.sh` grows download-only and install-from-archive modes.
  Provisioning never copies the local binary — that only works when both
  machines share an OS and CPU, and when they do not the copy still lands
  and still looks installed. The remote fetches its own build; if it
  cannot reach the release host, this machine fetches one for the
  remote's platform and pushes it with its checksum and signature so the
  remote repeats every check. Signing-key selection matches install.sh's
  own order exactly, and a forwarded key blanks the remote's own path so
  the two machines cannot end up on different trust roots.

`rocm services list --json` is the machine-readable listing the
orchestration reads back, applying the same liveness filter as the table.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
The unit tests drive a scripted stand-in, which proves the control flow
but assumes the real tools behave a certain way. These check that
assumption against a real OpenSSH server in a container — no GPU, no
ROCm, no tailnet, since the remote's `rocm` and `tailscale` are
stand-ins.

- `tests/remote-ssh/run.sh` checks the tool contracts: argument handling,
  exit-code propagation, a credential delivered on stdin and absent from
  the command line, file copy, batch-mode refusal, the shape Tailscale
  Funnel takes in the serve config, and that withdrawing a published
  endpoint actually removes it.
- `tests/remote-ssh/run-e2e.sh` drives the built binary through the whole
  flow: discover, probe, serve, publish, reconcile status, re-publish
  after an out-of-band withdrawal, tear down, and refuse to publish over
  a Funnel-exposed port.
- 14 cucumber scenarios in `features/remote.feature`; the six needing a
  host on the other end of a real SSH connection carry `@requires-docker`
  and skip with a reason where no container runtime exists.
- Both scripts run on a new `remote control channel (containerised)` CI
  lane, gated on the `heavy` path filter.

The Funnel fixtures use 443, not the default tailnet port: Funnel serves
only 443, 8443 and 10000, so an AllowFunnel entry on any other port is a
document the daemon cannot produce and a test against it proves nothing.

`resolve` takes an `Included` struct rather than a row of same-typed
bools, so a mis-ordered argument cannot silently change which set runs.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
Covers the `rocm remote` surface, and in docs/testing.md how to run the
two container-backed lanes — including the ROCM_TEST_APK_REPOS escape
hatch a network that intercepts TLS needs to build the fixture image.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 9c8edd9 to b2c5dd9 Compare September 15, 2026 10:46
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Pushed. Both findings from your review are fixed.

assess() ignoring out_of_scope — you're right, and the reason it's easy to miss is that diagnose() returns an empty matched in two opposite situations: a machine that was checked and is clean, and a platform with no catalog entries where nothing runs at all. rocm-core says so in prose — "nothing was checked -- this is not a clean bill of health" — and assess read only matched. Now a NotEvaluated refusal, checked before findings and before sudo, carrying the catalog's own sentence. Tested at the call site too: ensure_ready_with(.., install_rocm = true) must never issue install driver.

The exists() / write race — also right, and worse than stated. exists() runs before ensure_ready_with, so the window isn't a few instructions, it's the whole readiness probe, which can take minutes when it provisions a CLI. The credential write is now the claim itself — create_new, so O_EXCL — with a distinct error so the loser can be told from a full disk and correctly skips clear_key on a key it never owned. The test races sixteen concurrent serves and asserts exactly one returns Ok; a filesystem-end-state assertion cannot see this defect, because both runs write to the same name.

Four invariants nothing currently observes, each demonstrated by deleting the mechanism and watching the suite stay green rather than by argument:

  • Adding a verb to the daemon's read-only allowlist and not the CLI's leaves all 31 test binaries passing. Each side's test catches a known verb changing category; neither catches a new verb added on one side — which is the failure that already happened once here.
  • Renaming the --require-api-key clap flag leaves 823 tests green while rocm remote serve would send a flag the CLI rejects. That string is the mechanism behind this PR's "never unauthenticated" claim, and nothing joins the two ends.
  • The test named for services list --json agreeing with the table pins each side separately and never compares them.
  • Only the Rust side of the signing-key precedence is pinned; resolve_public_keys could be reordered and only a comment would notice.

All four are cross-boundary, and none is in the diff's behaviour — they are gaps in what is defended. Whether they belong in this PR or as follow-ups is your call: at ~10.8k lines across 31 files it is past what one review pass covers, and you have already said as much.

On your five minors — none addressed yet. One I would flag as more than minor: transport.rs's broken-pipe suppression depends on the read running first and the exit status reflecting it, and with ; the compound's status is whatever rocm serve returns. That invariant is currently held by a comment rather than by the shell, which is exactly the coupling the comment warns about.

@volen-silo
volen-silo dismissed stale reviews from juhovainio and siloteemu September 15, 2026 10:46

Both findings addressed in b2c5dd9 — see the reply comment. The five minors are not yet addressed.

@siloteemu

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · b2c5dd9

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds a rocm remote command family that provisions, serves and publishes a model on a tailnet GPU machine over SSH, plus a containerised SSH harness and Gherkin coverage. Outcome: Needs work — one test does not constrain the mechanism it names, which is a hard blocker; everything else found is non-blocking. Verified: the remote:: unit filter is green at this head (162 passed) and I ran nine branch-level mutations — O_EXCL credential claim, race-loser clear_key skip, store_key id guard, the out_of_scope refusal (both its presence and its ordering ahead of findings/sudo), and the validate_destination leading-dash guard are each now caught by a named test; deleting the peer sort and deleting the clear_key id guard each left all 162 green. The full test suite, clippy and the e2e suite were not run here. Leak scan over the diff is clean, and no prompt-injection content was found in any reviewed file. Check-run conclusions this review worked from: 19 success, 1 failure, 1 skipped, 0 pending — I did not attribute the failure to any job. Blocking: 1 · Non-blocking: 5.

Previous round

assess() ignoring out_of_scope — genuinely resolved, and well-tested. install.rs:67-71 returns Refusal::NotEvaluated carrying the catalog's own sentence, ahead of the findings check and the sudo check. Deleting that early return fails four tests, including the call-site test that asserts ensure_ready_with(.., install_rocm = true) never issues install driver. Separately, moving the check to after findings and after sudo — keeping it present — still fails an_unscored_platform_outranks_a_sudo_problem, so the ordering claim is tested and not merely asserted.

The exists()/write race — genuinely resolved. session.rs:251 claims the name with create_new (O_EXCL); removing the exclusivity fails two tests, including the sixteen-thread race. Making the race loser call clear_key also fails that race test. The exists() call at mod.rs:298 remains, but only selects wording inside an already-failing branch — it is no longer load-bearing.

One correction to the author's reasoning: the loser skipping clear_key is not produced by the distinct error type. Folding AlreadyExists back into the generic io error leaves all 162 tests green, because neither arm of that early-return block calls clear_key — the skip is structural. The distinct type's only observable effect is the message the loser sees, and nothing tests that.

Of the three previously-reported non-constraining tests, one is fixed and two are not. The validate_destination leading-dash guard is now constrained (deleting it fails a_name_ssh_would_read_as_an_option_is_refused). The peer sort is still unconstrained — see Blocking. The clear_key id guard is still unconstrained — see Non-blocking.

🚫 Blocking (must fix before merge)

apps/rocm/src/remote/tailnet.rs:453parsing_orders_peers_and_strips_the_magicdns_trailing_dot names the peer ordering and asserts ["gpu-box-1", "gpu-box-2", "phone"], but cannot fail when the ordering is removed. RawStatus.peers is a BTreeMap keyed by nodekey, and the fixture's keys (nodekey:aaa → gpu-box-1, nodekey:bbb → gpu-box-2, nodekey:ccc → phone) are already in host-alphabetical order, so into_values() yields exactly the asserted sequence before peers.sort_by(...) at tailnet.rs:225 ever runs. Deleting lines 225-230 leaves all 162 remote:: tests green — measured, not argued. No Gherkin scenario covers peer ordering either, so nothing else catches it. This is the same defect class the previous round reported for this test, and it is unchanged at this head. Fix: permute the fixture so nodekey order and host order disagree — give the alphabetically-last host the alphabetically-first nodekey (e.g. nodekey:aaaphone, nodekey:cccgpu-box-1) — so the assertion depends on the explicit sort. Two further assertions in the same test (peers[0].dns_name, the trailing-dot strip) are indexed by position and would become genuinely order-dependent at the same time.

Non-blocking

  • apps/rocm/src/remote/session.rs:639clear_key(&paths, "../../escaped") is followed by no assertion, so the comment's claim that "clearing one is a no-op rather than a delete somewhere else" is unverified; deleting the guard at session.rs:277 leaves all 162 tests green. Raised in the previous round, unaddressed. The store side of the same test is constrained. Cheap fix: write a sentinel file above the sessions directory and assert it survives the call. The guard is defence-in-depth rather than a live hole — read_one validates ids on read — which is worth saying in the comment so the next reader does not have to rediscover it.
  • apps/rocm/src/remote/transport.rs:451-456 — the comment justifies the broken-pipe demotion with "a broken pipe means the read never finished, which means the command cannot have exited 0". That does not hold: remote_serve_command joins with ;, and the piped key carries no trailing newline, so IFS= read -r returns nonzero at EOF whether it read the whole key or a truncated one, and ; lets the chain continue regardless. The code is safe anyway — the && !output.status.success() conjunct catches a truncated key paired with a successful serve — but the stated invariant is false, and the comment's own "reorder that command and this demotion needs rechecking" invites a maintainer to trust it. State the real invariant instead.
  • apps/rocm/src/remote/publish.rs:479 — after the off, PublishState::Foreign is folded into Ok(()) alongside Absent, uncommented, unlike every other arm in both matches in this function. The pre-check correctly refuses Foreign, so the realistic case is handled and the residual window is inherent (tailscale serve … off takes a port, with no compare-and-swap), but a foreign forward appearing on the port after our teardown is not the same fact as a clean withdrawal and should not read as one. Separate the arms, or at minimum warn.
  • .github/workflows/ci.yml:669 — the new remote-ssh job carries only needs: changes, while build-and-test, test and windows-build-and-test all carry needs: [changes, clippy, prek] plus if: github.event_name != 'workflow_dispatch', with a comment explaining both. This job builds and runs a 30-minute container suite, so it is the same weight class; nothing in its own comments explains the deviation. Either adopt the sibling pattern or record why it differs.
  • apps/rocm/src/remote/provision.rs:160 — the remote staging directory is created with a plain mkdir -p and never tightened, while the local side uses an atomic DirBuilder::mode(0o700) whose surrounding comment argues carefully for exactly that. The contents are public release material, not secrets, so the exposure is narrow, but mkdir -p -m 700 costs nothing and matches the stated intent.

Check-run conclusions were re-read immediately before posting and are unchanged from the counts stated above.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · b2c5dd9

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Blocking: a test does not constrain the mechanism it names.

parsing_orders_peers_and_strips_the_magicdns_trailing_dot in apps/rocm/src/remote/tailnet.rs names the peer ordering and asserts a specific host sequence, but cannot fail when the ordering is removed. The peers map is keyed by nodekey, and the fixture's keys are already in host-alphabetical order, so iterating the map yields exactly the asserted sequence before the explicit sort ever runs. Deleting the sort leaves every test in that module green — measured, not argued. No scenario covers peer ordering either, so nothing else catches it.

This is the same defect class reported in the previous round for this test, and it is unchanged at this head. A weak test is rarely strengthened after merge, and once it lands CI certifies the gap.

Suggested fix: permute the fixture so nodekey order and host order disagree — give the alphabetically-last host the alphabetically-first nodekey — so the assertion depends on the explicit sort. Two further assertions in the same test are indexed by position and would become genuinely order-dependent at the same time.

The two blocking findings from the previous round are both genuinely resolved, with the fixes independently constrained by tests; the remaining observations are non-blocking and are in the review comment on this PR.

@juhovainio juhovainio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Followed up on the still-open minor items from my last pass, to weigh whether any of them should actually block merge.

mod.rs:604 (; instead of &&) — the one I'd push for before merge.
The broken-pipe-suppression logic in transport.rs decides "key write failed → state unknown, don't clear the key" specifically because it assumes the read running first means a broken pipe implies nothing started. That assumption is currently true only because of prose, not because the shell enforces it — ; doesn't. Today it's safe (the command shape is pinned and tested). The risk is entirely about the next person who touches that remote command: if it's ever reordered without noticing this coupling, the failure-classification logic silently goes wrong on the credential path, and nothing catches it because the existing test asserts on strings, not exit status. This is a cheap fix (;&&) for a real regression trap in the API-key path — worth doing now rather than as follow-up.

provision.rs:160 (staging dir, no explicit 0700).
Only matters on a shared multi-user remote box with a permissive umask. Worst case is a local user on that box reading (or, with a lax umask, racing) the pushed archive/checksum/signature before verification — but the signature gate still has to be defeated for it to matter, so the practical impact is information disclosure or a corrupted install, not a bypass. Low risk for the common single-tenant GPU box case. Worth fixing for parity with the local 0700 helper, but not merge-blocking.

publish.rs:449 (no ownership recheck before teardown).
Even the requested fix only narrows the window rather than closing it — only the daemon could make it atomic. There's no available fix that meaningfully changes the risk profile, so leaving it open costs nothing beyond what a fix would have left anyway. Fine as a documented limitation.

ci.yml:666-667 (missing needs: and dispatch guard).
Zero correctness/security risk, pure CI cost. Fine as follow-up.

Net: the two Major issues (install.rs out-of-scope gate, session.rs key race) are fixed and verified against the live code — cargo test -p rocm --bin rocm remote:: passes 162/162 including the new tests. Of the remaining minors, only the ;/&& one sits in a security-relevant path; I'd like that one addressed before merge, and I'm fine with the other three as follow-up.

@juhovainio juhovainio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving with the minor findings above - please address them to an extend that seems good in the scope of this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants